From e4910aeba57f0147fe2dd8b30088ea15dba26e33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:33:14 +0000 Subject: [PATCH] Fase 2: Query Store analysis (plan regressions, failed forced plans, parameter sensitivity) Collector reads the 100 heaviest queries per Query Store database, split into the last 24 hours and the week before. Rules: - SQLI-QS-001 plan regression: new plan >= 2x slower than the best plan from the week before and >= 60 s extra per day; scripts the force - SQLI-QS-002 forced plan that fails (e.g. NO_INDEX); scripts the unforce - SQLI-QS-003 parameter sensitivity: very different plans or volatile durations; suggests PSP optimization / Query Store hints on 2022+ Also: backups taken in the same second a database was created count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- .../BuiltInCollectors.cs | 1 + .../Databases/DatabaseCollector.cs | 15 +- .../Performance/QueryStoreCollector.cs | 121 +++++++++++ src/SQLInsight.Core/Data/QueryStore.cs | 55 +++++ src/SQLInsight.Rules/BuiltInRules.cs | 3 + .../Performance/QueryStoreRules.cs | 205 ++++++++++++++++++ .../CollectorTests.cs | 41 ++++ .../QueryStoreRuleTests.cs | 130 +++++++++++ 8 files changed, 564 insertions(+), 7 deletions(-) create mode 100644 src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs create mode 100644 src/SQLInsight.Core/Data/QueryStore.cs create mode 100644 src/SQLInsight.Rules/Performance/QueryStoreRules.cs create mode 100644 tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs 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); + } +}