diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs index d4f7421..bf1d667 100644 --- a/src/SQLInsight.Collectors/BuiltInCollectors.cs +++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs @@ -21,5 +21,6 @@ public static class BuiltInCollectors new CpuCollector(), new BlockingCollector(), new DeadlockCollector(), + new QueryStoreCollector(), ]; } diff --git a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs index 3bfedcb..9d7bac2 100644 --- a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs +++ b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs @@ -10,7 +10,8 @@ namespace SQLInsight.Collectors.Databases; public sealed class DatabaseCollector : Collector { // Backups of an earlier database with the same name (dropped and recreated) must not count, hence the - // create_date comparison. + // create_date comparison. backupset stores whole seconds, so allow one second: a backup taken in the same + // second the database was created must count. private const string BackupJoin = """ LEFT JOIN ( SELECT database_name, @@ -103,9 +104,9 @@ public sealed class DatabaseCollector : Collector {agSecondary} THEN 1 ELSE 0 END, {inAg}, - CASE WHEN b.last_full >= d.create_date THEN b.last_full END, - CASE WHEN b.last_diff >= d.create_date THEN b.last_diff END, - CASE WHEN b.last_log >= d.create_date THEN b.last_log END, + CASE WHEN b.last_full >= DATEADD(second, -1, d.create_date) THEN b.last_full END, + CASE WHEN b.last_diff >= DATEADD(second, -1, d.create_date) THEN b.last_diff END, + CASE WHEN b.last_log >= DATEADD(second, -1, d.create_date) THEN b.last_log END, {checkDb} FROM sys.databases d {BackupJoin} @@ -126,9 +127,9 @@ public sealed class DatabaseCollector : Collector d.crdate, 0, 0, - CASE WHEN b.last_full >= d.crdate THEN b.last_full END, - CASE WHEN b.last_diff >= d.crdate THEN b.last_diff END, - CASE WHEN b.last_log >= d.crdate THEN b.last_log END, + CASE WHEN b.last_full >= DATEADD(second, -1, d.crdate) THEN b.last_full END, + CASE WHEN b.last_diff >= DATEADD(second, -1, d.crdate) THEN b.last_diff END, + CASE WHEN b.last_log >= DATEADD(second, -1, d.crdate) THEN b.last_log END, CAST(NULL AS datetime) FROM master.dbo.sysdatabases d {BackupJoin} diff --git a/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs b/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs new file mode 100644 index 0000000..e0d7166 --- /dev/null +++ b/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs @@ -0,0 +1,121 @@ +using System.Data.Common; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Performance; + +/// +/// Reads Query Store from every user database that has it on. Only the 100 heaviest queries (by total duration over +/// eight days) are returned per database, so the cost stays bounded even with a large Query Store. +/// +public sealed class QueryStoreCollector : Collector +{ + public const int TopQueries = 100; + + internal const string DatabasesSql = """ + SELECT name FROM sys.databases + WHERE is_query_store_on = 1 AND state = 0 AND database_id > 4 + """; + + public override string Name => "query-store"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30); + public override IReadOnlyCollection RequiredCapabilities => [Capability.QueryStore]; + + protected override async Task CollectAsync(CollectorContext context, QueryStoreSnapshot? previous) + { + var ct = context.CancellationToken; + var names = await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false); + var databases = new List(names.Count); + foreach (var name in names) + { + databases.Add(await ReadAsync(context.Connection, name, ct).ConfigureAwait(false)); + } + + return new QueryStoreSnapshot { CapturedAt = context.Now, Databases = databases }; + } + + private static async Task ReadAsync(DbConnection connection, string database, CancellationToken ct) + { + try + { + var plans = await Db.QueryAsync(connection, BuildStatsSql(database), r => new QueryPlanStats + { + QueryId = r.Long(0), + PlanId = r.Long(1), + IsRecent = r.Bool(2), + Executions = r.Long(3), + TotalDurationUs = r.Double(4), + TotalCpuUs = r.Double(5), + TotalLogicalReads = r.Double(6), + MaxStdevDurationUs = r.Double(7), + MaxDurationUs = r.Double(8), + MinDurationUs = r.Double(9), + LastExecution = r.IsNull(10) ? null : new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(10), DateTimeKind.Utc)), + IsForced = r.Bool(11), + QueryText = r.NullableString(12), + ObjectName = r.NullableString(13), + }, ct).ConfigureAwait(false); + + var failed = await Db.QueryAsync(connection, BuildFailedForcedSql(database), + r => new FailedForcedPlan(r.Long(0), r.Long(1), r.Long(2), r.NullableString(3), r.NullableString(4)), ct).ConfigureAwait(false); + + return new QueryStoreDatabase { Name = database, Plans = plans, FailedForcedPlans = failed }; + } + catch (DbException ex) + { + return new QueryStoreDatabase { Name = database, Plans = [], Error = ex.Message }; + } + } + + internal static string BuildStatsSql(string database) + { + var db = Db.Identifier(database); + return $""" + DECLARE @now datetimeoffset = SYSDATETIMEOFFSET(); + DECLARE @recent datetimeoffset = DATEADD(hour, -24, @now); + DECLARE @history datetimeoffset = DATEADD(day, -8, @now); + + WITH stats AS ( + SELECT p.query_id, p.plan_id, + CASE WHEN i.start_time >= @recent THEN 1 ELSE 0 END AS is_recent, + SUM(rs.count_executions) AS execs, + SUM(rs.avg_duration * rs.count_executions) AS total_duration, + SUM(rs.avg_cpu_time * rs.count_executions) AS total_cpu, + SUM(rs.avg_logical_io_reads * rs.count_executions) AS total_reads, + MAX(rs.stdev_duration) AS max_stdev_duration, + MAX(rs.max_duration) AS max_duration, + MIN(rs.min_duration) AS min_duration, + MAX(rs.last_execution_time) AS last_execution + FROM {db}.sys.query_store_runtime_stats rs + JOIN {db}.sys.query_store_runtime_stats_interval i ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id + JOIN {db}.sys.query_store_plan p ON p.plan_id = rs.plan_id + WHERE i.start_time >= @history + GROUP BY p.query_id, p.plan_id, CASE WHEN i.start_time >= @recent THEN 1 ELSE 0 END + ), top_queries AS ( + SELECT TOP ({TopQueries}) query_id FROM stats GROUP BY query_id ORDER BY SUM(total_duration) DESC + ) + SELECT s.query_id, s.plan_id, s.is_recent, s.execs, s.total_duration, s.total_cpu, s.total_reads, + s.max_stdev_duration, s.max_duration, s.min_duration, + CAST(SWITCHOFFSET(s.last_execution, '+00:00') AS datetime2(0)), + p.is_forced_plan, LEFT(qt.query_sql_text, 2000), + OBJECT_SCHEMA_NAME(q.object_id, DB_ID({Db.Literal(database)})) + N'.' + OBJECT_NAME(q.object_id, DB_ID({Db.Literal(database)})) + FROM stats s + JOIN top_queries t ON t.query_id = s.query_id + JOIN {db}.sys.query_store_plan p ON p.plan_id = s.plan_id + JOIN {db}.sys.query_store_query q ON q.query_id = s.query_id + JOIN {db}.sys.query_store_query_text qt ON qt.query_text_id = q.query_text_id; + """; + } + + internal static string BuildFailedForcedSql(string database) + { + var db = Db.Identifier(database); + return $""" + SELECT p.query_id, p.plan_id, p.force_failure_count, p.last_force_failure_reason_desc, LEFT(qt.query_sql_text, 2000) + FROM {db}.sys.query_store_plan p + JOIN {db}.sys.query_store_query q ON q.query_id = p.query_id + JOIN {db}.sys.query_store_query_text qt ON qt.query_text_id = q.query_text_id + WHERE p.is_forced_plan = 1 AND p.force_failure_count > 0; + """; + } +} diff --git a/src/SQLInsight.Core/Data/QueryStore.cs b/src/SQLInsight.Core/Data/QueryStore.cs new file mode 100644 index 0000000..f978c71 --- /dev/null +++ b/src/SQLInsight.Core/Data/QueryStore.cs @@ -0,0 +1,55 @@ +namespace SQLInsight.Core.Data; + +/// +/// Query Store statistics per database (2016+): the heaviest queries of the last eight days, split into the last +/// 24 hours and the week before, so plan changes and regressions can be seen. +/// +public sealed record QueryStoreSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required IReadOnlyList Databases { get; init; } +} + +public sealed record QueryStoreDatabase +{ + public required string Name { get; init; } + public required IReadOnlyList Plans { get; init; } + public IReadOnlyList FailedForcedPlans { get; init; } = []; + + /// Set when this database's Query Store could not be read. + public string? Error { get; init; } +} + +/// +/// One plan of one query in one window. Durations and CPU are in microseconds, as Query Store stores them. +/// +public sealed record QueryPlanStats +{ + public required long QueryId { get; init; } + public required long PlanId { get; init; } + + /// True for the last 24 hours, false for the seven days before. + public required bool IsRecent { get; init; } + + public required long Executions { get; init; } + public required double TotalDurationUs { get; init; } + public required double TotalCpuUs { get; init; } + public required double TotalLogicalReads { get; init; } + + /// The largest per-interval standard deviation of duration (an approximation across intervals). + public required double MaxStdevDurationUs { get; init; } + + public required double MaxDurationUs { get; init; } + public required double MinDurationUs { get; init; } + public DateTimeOffset? LastExecution { get; init; } + public bool IsForced { get; init; } + public string? QueryText { get; init; } + public string? ObjectName { get; init; } + + public double AverageDurationUs => Executions == 0 ? 0 : TotalDurationUs / Executions; + public double AverageCpuUs => Executions == 0 ? 0 : TotalCpuUs / Executions; + public double AverageLogicalReads => Executions == 0 ? 0 : TotalLogicalReads / Executions; +} + +/// A plan forced with sp_query_store_force_plan that SQL Server could not use. +public sealed record FailedForcedPlan(long QueryId, long PlanId, long FailureCount, string? Reason, string? QueryText); diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index 1fb148a..4ea6aea 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -45,6 +45,9 @@ public static class BuiltInRules new LogFileWriteLatencyRule(), new CpuRule(), new MemoryGrantsPendingRule(), + new PlanRegressionRule(), + new FailedForcedPlanRule(), + new ParameterSensitivityRule(), // Concurrency new BlockingRule(), diff --git a/src/SQLInsight.Rules/Performance/QueryStoreRules.cs b/src/SQLInsight.Rules/Performance/QueryStoreRules.cs new file mode 100644 index 0000000..93c6f0d --- /dev/null +++ b/src/SQLInsight.Rules/Performance/QueryStoreRules.cs @@ -0,0 +1,205 @@ +using System.Globalization; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Performance; + +internal static class QueryStoreText +{ + public const int MaxFindingsPerRule = 10; + + public static string Ms(double microseconds) => $"{Format.Number(microseconds / 1000, microseconds < 10_000 ? 1 : 0)} ms"; + + public static string Seconds(double microseconds) => $"{Format.Number(microseconds / 1_000_000)} s"; + + public static string Describe(QueryPlanStats plan) => + plan.ObjectName is { Length: > 0 } name ? $"{name}: {Shorten(plan.QueryText)}" : Shorten(plan.QueryText); + + public static string Shorten(string? text) + { + var flat = string.Join(' ', (text ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return flat.Length <= 300 ? flat : flat[..300] + " …"; + } + + public static string Use(string database) => $"USE [{database.Replace("]", "]]", StringComparison.Ordinal)}];"; + + public static Reference Docs => Engine.Docs.Learn("Query Store usage scenarios", "relational-databases/performance/query-store-usage-scenarios"); +} + +/// +/// A query got a new plan in the last 24 hours that is much slower than its best plan from the week before. +/// The classic case for forcing the old plan. +/// +public sealed class PlanRegressionRule : Rule +{ + public const int MinExecutions = 10; + public const double MinSlowdown = 2.0; + + /// Extra time per day the regression must cost before it is worth reporting. + public const double MinExtraDurationUs = 60_000_000; + + public override string Id => "SQLI-QS-001"; + public override string Title => "Planregression: forespørgsel har fået en langsommere plan"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.QueryStore]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var findings = new List<(double Extra, Finding Finding)>(); + foreach (var database in context.Data.Get().Databases) + { + foreach (var query in database.Plans.GroupBy(p => p.QueryId)) + { + var best = query.Where(p => !p.IsRecent && p.Executions >= MinExecutions).MinBy(p => p.AverageDurationUs); + if (best is null) + { + continue; + } + + // A list, not MaxBy on the sequence: MaxBy throws on an empty sequence of value tuples. + var candidates = query + .Where(p => p.IsRecent && p.PlanId != best.PlanId && p.Executions >= MinExecutions && p.AverageDurationUs >= best.AverageDurationUs * MinSlowdown) + .Select(p => (Plan: p, Extra: (p.AverageDurationUs - best.AverageDurationUs) * p.Executions)) + .Where(x => x.Extra >= MinExtraDurationUs) + .ToList(); + if (candidates.Count == 0) + { + continue; + } + + var worst = candidates.MaxBy(x => x.Extra); + var slow = worst.Plan; + + var factor = slow.AverageDurationUs / best.AverageDurationUs; + findings.Add((worst.Extra, CreateFinding( + worst.Extra >= 10 * MinExtraDurationUs ? Severity.High : Severity.Medium, + $"{database.Name}: query {query.Key} er blevet {Format.Number(factor, 1)}× langsommere med plan {slow.PlanId}", + "Forespørgslen har inden for det seneste døgn brugt en ny plan, som er markant langsommere end den bedste plan " + + "fra ugen før. Det skyldes typisk, at planen er genkompileret med andre parameterværdier (parameter sniffing), " + + "efter opdaterede statistikker eller efter en ændring af indexes. Den ekstra tid rammer hver eneste udførelse.", + $"Sammenlign planerne i SSMS (Query Store → Regressed Queries). Er plan {best.PlanId} god i alle tilfælde, kan den " + + "forceres som akut løsning, og årsagen findes bagefter. Følg op på, om forceringen lykkes (se SQLI-QS-002). " + + "På Enterprise Edition kan automatic tuning (FORCE_LAST_GOOD_PLAN) gøre dette automatisk.", + [ + new Evidence("Forespørgsel", QueryStoreText.Describe(slow.QueryText is null ? best : slow)), + new Evidence($"Ny plan {slow.PlanId} (seneste døgn)", $"{Format.Number(slow.Executions)} udførelser, gns. {QueryStoreText.Ms(slow.AverageDurationUs)}, {Format.Number(slow.AverageLogicalReads)} læsninger"), + new Evidence($"Bedste plan {best.PlanId} (ugen før)", $"{Format.Number(best.Executions)} udførelser, gns. {QueryStoreText.Ms(best.AverageDurationUs)}, {Format.Number(best.AverageLogicalReads)} læsninger"), + new Evidence("Ekstra tid seneste døgn", QueryStoreText.Seconds(worst.Extra)), + ], + subject: $"query: {database.Name}/{query.Key}", + fixScript: $"{QueryStoreText.Use(database.Name)}\nEXEC sys.sp_query_store_force_plan @query_id = {query.Key}, @plan_id = {best.PlanId};", + references: QueryStoreText.Docs))); + } + } + + return Result(findings.OrderByDescending(f => f.Extra).Take(QueryStoreText.MaxFindingsPerRule).Select(f => f.Finding)); + } +} + +/// +/// A plan forced in Query Store that SQL Server can no longer use (e.g. an index it needs was dropped). +/// +public sealed class FailedForcedPlanRule : Rule +{ + public override string Id => "SQLI-QS-002"; + public override string Title => "Forceret plan kan ikke bruges"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.QueryStore]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + return Result(context.Data.Get().Databases.SelectMany(db => db.FailedForcedPlans.Select(f => CreateFinding( + Severity.High, + $"{db.Name}: forceret plan {f.PlanId} for query {f.QueryId} fejler ({f.Reason ?? "ukendt årsag"})", + "Planen er forceret i Query Store, men SQL Server kan ikke genskabe den, typisk fordi et index, den bruger, er slettet " + + "eller omdøbt (NO_INDEX), eller fordi skemaet er ændret. Forespørgslen kører så med en ny plan, måske netop den " + + "dårlige, forceringen skulle forhindre, og hver kompilering bruger ekstra tid på det mislykkede forsøg.", + f.Reason == "NO_INDEX" + ? "Genopret det index, planen bruger (se planens XML), eller fjern forceringen og find en ny god plan." + : "Fjern forceringen, og vurdér forespørgslen igen. Se last_force_failure_reason_desc i sys.query_store_plan.", + [ + new Evidence("Forespørgsel", QueryStoreText.Shorten(f.QueryText)), + new Evidence("Fejl", $"{Format.Number(f.FailureCount)} gange, senest {f.Reason}"), + ], + subject: $"query: {db.Name}/{f.QueryId}", + fixScript: $"{QueryStoreText.Use(db.Name)}\nEXEC sys.sp_query_store_unforce_plan @query_id = {f.QueryId}, @plan_id = {f.PlanId};", + references: QueryStoreText.Docs))).Take(QueryStoreText.MaxFindingsPerRule)); + } +} + +/// +/// A query whose duration swings wildly between executions, or that alternates between very different plans: +/// the signature of parameter sensitivity (parameter sniffing). +/// +public sealed class ParameterSensitivityRule : Rule +{ + public const int MinExecutions = 100; + public const double MinMaxDurationUs = 1_000_000; + + public override string Id => "SQLI-QS-003"; + public override string Title => "Parameterfølsom forespørgsel (parameter sniffing)"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.QueryStore]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var version = context.Instance.Version; + var findings = new List<(double Weight, Finding Finding)>(); + foreach (var database in context.Data.Get().Databases) + { + foreach (var query in database.Plans.Where(p => p.IsRecent).GroupBy(p => p.QueryId)) + { + var plans = query.Where(p => p.Executions >= 10).ToList(); + var busiest = plans.MaxBy(p => p.Executions); + if (busiest is null) + { + continue; + } + + var fastest = plans.Min(p => p.AverageDurationUs); + var slowest = plans.Max(p => p.AverageDurationUs); + var multiplePlans = plans.Count >= 2 && fastest > 0 && slowest / fastest >= 10 && slowest >= 100_000; + var volatileDuration = busiest.Executions >= MinExecutions && busiest.MaxDurationUs >= MinMaxDurationUs + && busiest.AverageDurationUs > 0 + && busiest.MaxDurationUs / busiest.AverageDurationUs >= 20 + && busiest.MaxStdevDurationUs / busiest.AverageDurationUs >= 2; + if (!multiplePlans && !volatileDuration) + { + continue; + } + + var evidence = new List { new("Forespørgsel", QueryStoreText.Describe(busiest)) }; + evidence.AddRange(plans.OrderBy(p => p.AverageDurationUs).Select(p => new Evidence( + $"Plan {p.PlanId}", + $"{Format.Number(p.Executions)} udførelser, gns. {QueryStoreText.Ms(p.AverageDurationUs)}, min. {QueryStoreText.Ms(p.MinDurationUs)}, maks. {QueryStoreText.Ms(p.MaxDurationUs)}"))); + + var fix = version.Major >= 16 + ? $"{QueryStoreText.Use(database.Name)}\n-- Midlertidigt, uden kodeændring (SQL Server 2022+):\nEXEC sys.sp_query_store_set_hints @query_id = {query.Key}, @query_hints = N'OPTION (RECOMPILE)';" + : null; + + findings.Add((plans.Sum(p => p.TotalDurationUs), CreateFinding( + Severity.Medium, + $"{database.Name}: query {query.Key} har meget svingende svartider", + multiplePlans + ? $"Forespørgslen har det seneste døgn brugt {plans.Count} planer, hvor den langsomste i gennemsnit er {Format.Number(slowest / fastest)}× langsommere end den hurtigste. " + + "Planen vælges ud fra parameterværdierne ved kompilering; passer den ikke til de næste værdier, bliver det langsomt." + : $"Den samme plan tager i gennemsnit {QueryStoreText.Ms(busiest.AverageDurationUs)}, men op til {QueryStoreText.Ms(busiest.MaxDurationUs)}. " + + "Så store udsving skyldes typisk skævt fordelte data, hvor én plan ikke passer til alle parameterværdier.", + (version.Major >= 16 + ? "På SQL Server 2022 med compatibility level 160 kan Parameter Sensitive Plan optimization løse det automatisk. " + : string.Empty) + + "Ellers: vælg ud fra, hvor ofte forespørgslen kører: OPTION (RECOMPILE) til sjældne, tunge forespørgsler; " + + "OPTIMIZE FOR til en repræsentativ værdi; eller del forespørgslen op efter datafordelingen. " + + "Query Store hints (2022+) kan afprøve et hint uden kodeændring.", + evidence, + subject: $"query: {database.Name}/{query.Key}", + fixScript: fix, + references: Docs.Learn("Parameter Sensitive Plan optimization", "relational-databases/performance/parameter-sensitive-plan-optimization")))); + } + } + + return Result(findings.OrderByDescending(f => f.Weight).Take(QueryStoreText.MaxFindingsPerRule).Select(f => f.Finding)); + } +} diff --git a/tests/SQLInsight.IntegrationTests/CollectorTests.cs b/tests/SQLInsight.IntegrationTests/CollectorTests.cs index bdb6741..653613b 100644 --- a/tests/SQLInsight.IntegrationTests/CollectorTests.cs +++ b/tests/SQLInsight.IntegrationTests/CollectorTests.cs @@ -185,6 +185,47 @@ public class CollectorTests(SqlServerFixture sql) Assert.Empty(again.NewDeadlocks); } + [Fact] + public async Task Query_store_reports_plans_and_failed_forced_plans() + { + // Force a plan that uses an index, then drop the index: forcing fails with NO_INDEX. + // The statement lives in a procedure so it is not auto-parameterized and can be found by object. The data is + // selective enough that the optimizer seeks on the index by itself. + await sql.ExecuteAsSaAsync($""" + USE [{SqlServerFixture.TestDatabase}]; + IF OBJECT_ID(N'dbo.QsProbe') IS NULL + BEGIN + CREATE TABLE dbo.QsProbe (Id int IDENTITY PRIMARY KEY, Code int NOT NULL, Filler char(200) NOT NULL DEFAULT 'x'); + INSERT dbo.QsProbe (Code) SELECT TOP (50000) ABS(CHECKSUM(NEWID())) % 20000 FROM sys.all_objects a CROSS JOIN sys.all_objects b; + END; + IF INDEXPROPERTY(OBJECT_ID(N'dbo.QsProbe'), N'IX_QsProbe_Code', 'IndexID') IS NULL + CREATE INDEX IX_QsProbe_Code ON dbo.QsProbe (Code); + """); + await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC (N'CREATE OR ALTER PROCEDURE dbo.QsProbeProc @code int AS SELECT Id, Filler FROM dbo.QsProbe WHERE Code = @code;');"); + await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC dbo.QsProbeProc 1; EXEC dbo.QsProbeProc 2; EXEC dbo.QsProbeProc 3; EXEC dbo.QsProbeProc 4; EXEC dbo.QsProbeProc 5; EXEC sys.sp_query_store_flush_db;"); + + await sql.ExecuteAsSaAsync($""" + USE [{SqlServerFixture.TestDatabase}]; + DECLARE @q bigint, @p bigint; + SELECT TOP (1) @q = q.query_id, @p = p.plan_id + FROM sys.query_store_query q JOIN sys.query_store_plan p ON p.query_id = q.query_id + WHERE q.object_id = OBJECT_ID(N'dbo.QsProbeProc'); + EXEC sys.sp_query_store_force_plan @q, @p; + DROP INDEX IX_QsProbe_Code ON dbo.QsProbe; + """); + await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC dbo.QsProbeProc 42; EXEC sys.sp_query_store_flush_db;"); + + await using var session = await AgentSession.OpenAsync(sql); + var snapshot = await session.CollectAsync(new QueryStoreCollector()); + + var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase); + Assert.Null(db.Error); + var probe = Assert.Single(db.Plans, p => p.IsRecent && p.ObjectName == "dbo.QsProbeProc" && p.Executions >= 5); + Assert.True(probe.IsForced); + var failed = Assert.Single(db.FailedForcedPlans); + Assert.Equal("NO_INDEX", failed.Reason); + } + private static async Task WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous) { // system_health dispatches events to its targets with up to 120 seconds latency. diff --git a/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs b/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs new file mode 100644 index 0000000..b3df142 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs @@ -0,0 +1,130 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Rules.Engine; +using SQLInsight.Rules.Performance; +using static SQLInsight.Rules.Tests.TestInstances; + +namespace SQLInsight.Rules.Tests; + +public class QueryStoreRuleTests +{ + private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context)); + + private static QueryPlanStats Plan(long query, long plan, bool recent, long execs, double avgMs, double maxMs = 0, double stdevMs = 0) => new() + { + QueryId = query, + PlanId = plan, + IsRecent = recent, + Executions = execs, + TotalDurationUs = execs * avgMs * 1000, + TotalCpuUs = execs * avgMs * 800, + TotalLogicalReads = execs * 1000, + MaxStdevDurationUs = stdevMs * 1000, + MaxDurationUs = (maxMs == 0 ? avgMs : maxMs) * 1000, + MinDurationUs = avgMs * 500, + QueryText = "SELECT * FROM dbo.Orders WHERE CustomerId = @p0", + ObjectName = "dbo.GetOrders", + }; + + private static RuleContext Store(params QueryPlanStats[] plans) => + With(new QueryStoreSnapshot { CapturedAt = Now, Databases = [new QueryStoreDatabase { Name = "Sales", Plans = plans }] }); + + [Fact] + public void New_slow_plan_is_a_regression_with_force_script() + { + var result = Run(new PlanRegressionRule(), Store( + Plan(7, 1, recent: false, execs: 5000, avgMs: 20), + Plan(7, 1, recent: true, execs: 200, avgMs: 20), + Plan(7, 2, recent: true, execs: 3000, avgMs: 400))); + + var finding = Assert.Single(result.Findings); + Assert.Equal(Severity.High, finding.Severity); // 3000 × 380 ms = 1140 s extra + Assert.Equal("query: Sales/7", finding.Subject); + Assert.Contains("@query_id = 7, @plan_id = 1", finding.FixScript, StringComparison.Ordinal); + Assert.StartsWith("USE [Sales];", finding.FixScript, StringComparison.Ordinal); + } + + [Fact] + public void Slowdown_too_small_to_matter_is_ignored() + { + // 3× slower, but only 20 executions × 40 ms = 0.8 s extra per day. + var result = Run(new PlanRegressionRule(), Store( + Plan(7, 1, recent: false, execs: 100, avgMs: 20), + Plan(7, 2, recent: true, execs: 20, avgMs: 60))); + + Assert.Equal(RuleOutcome.Passed, result.Outcome); + } + + [Fact] + public void Same_plan_getting_slower_is_not_a_plan_regression() + { + var result = Run(new PlanRegressionRule(), Store( + Plan(7, 1, recent: false, execs: 5000, avgMs: 20), + Plan(7, 1, recent: true, execs: 5000, avgMs: 200))); + + Assert.Equal(RuleOutcome.Passed, result.Outcome); + } + + [Fact] + public void Failed_forced_plan_is_high_with_unforce_script() + { + var context = With(new QueryStoreSnapshot + { + CapturedAt = Now, + Databases = [new QueryStoreDatabase { Name = "Sales", Plans = [], FailedForcedPlans = [new FailedForcedPlan(7, 3, 12, "NO_INDEX", "SELECT 1")] }], + }); + + var finding = Assert.Single(Run(new FailedForcedPlanRule(), context).Findings); + + Assert.Equal(Severity.High, finding.Severity); + Assert.Contains("Genopret det index", finding.Recommendation, StringComparison.Ordinal); + Assert.Contains("sp_query_store_unforce_plan @query_id = 7, @plan_id = 3", finding.FixScript, StringComparison.Ordinal); + } + + [Fact] + public void Plans_with_very_different_durations_indicate_parameter_sensitivity() + { + var finding = Assert.Single(Run(new ParameterSensitivityRule(), Store( + Plan(9, 1, recent: true, execs: 900, avgMs: 5), + Plan(9, 2, recent: true, execs: 100, avgMs: 2000))).Findings); + + Assert.Contains("2 planer", finding.Explanation, StringComparison.Ordinal); + Assert.Contains("sp_query_store_set_hints", finding.FixScript, StringComparison.Ordinal); // 2022 test instance + } + + [Fact] + public void Volatile_single_plan_indicates_parameter_sensitivity() + { + var result = Run(new ParameterSensitivityRule(), Store(Plan(9, 1, recent: true, execs: 5000, avgMs: 50, maxMs: 8000, stdevMs: 300))); + + Assert.Equal(RuleOutcome.Findings, result.Outcome); + } + + [Fact] + public void Stable_query_is_not_parameter_sensitive() + { + var result = Run(new ParameterSensitivityRule(), Store(Plan(9, 1, recent: true, execs: 5000, avgMs: 50, maxMs: 120, stdevMs: 10))); + + Assert.Equal(RuleOutcome.Passed, result.Outcome); + } + + [Fact] + public void No_hints_script_before_2022() + { + var context = WithVersion("15.0.4490.9", new QueryStoreSnapshot + { + CapturedAt = Now, + Databases = [new QueryStoreDatabase { Name = "Sales", Plans = [Plan(9, 1, true, 900, 5), Plan(9, 2, true, 100, 2000)] }], + }); + + Assert.Null(Assert.Single(Run(new ParameterSensitivityRule(), context).Findings).FixScript); + } + + [Fact] + public void Query_store_rules_are_not_measurable_before_2016() + { + var result = Run(new PlanRegressionRule(), WithVersion("12.0.6024.0")); + + Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + } +}