Fase 2: Query Store analysis (plan regressions, failed forced plans, parameter sensitivity)
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 07:33:14 +00:00
parent 3c35f92785
commit e4910aeba5
No known key found for this signature in database
8 changed files with 564 additions and 7 deletions

View file

@ -21,5 +21,6 @@ public static class BuiltInCollectors
new CpuCollector(), new CpuCollector(),
new BlockingCollector(), new BlockingCollector(),
new DeadlockCollector(), new DeadlockCollector(),
new QueryStoreCollector(),
]; ];
} }

View file

@ -10,7 +10,8 @@ namespace SQLInsight.Collectors.Databases;
public sealed class DatabaseCollector : Collector<DatabaseSnapshot> public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
{ {
// Backups of an earlier database with the same name (dropped and recreated) must not count, hence the // 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 = """ private const string BackupJoin = """
LEFT JOIN ( LEFT JOIN (
SELECT database_name, SELECT database_name,
@ -103,9 +104,9 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
{agSecondary} {agSecondary}
THEN 1 ELSE 0 END, THEN 1 ELSE 0 END,
{inAg}, {inAg},
CASE WHEN b.last_full >= d.create_date THEN b.last_full END, CASE WHEN b.last_full >= DATEADD(second, -1, 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_diff >= DATEADD(second, -1, 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_log >= DATEADD(second, -1, d.create_date) THEN b.last_log END,
{checkDb} {checkDb}
FROM sys.databases d FROM sys.databases d
{BackupJoin} {BackupJoin}
@ -126,9 +127,9 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
d.crdate, d.crdate,
0, 0,
0, 0,
CASE WHEN b.last_full >= d.crdate THEN b.last_full END, CASE WHEN b.last_full >= DATEADD(second, -1, d.crdate) THEN b.last_full END,
CASE WHEN b.last_diff >= d.crdate THEN b.last_diff END, CASE WHEN b.last_diff >= DATEADD(second, -1, d.crdate) THEN b.last_diff END,
CASE WHEN b.last_log >= d.crdate THEN b.last_log END, CASE WHEN b.last_log >= DATEADD(second, -1, d.crdate) THEN b.last_log END,
CAST(NULL AS datetime) CAST(NULL AS datetime)
FROM master.dbo.sysdatabases d FROM master.dbo.sysdatabases d
{BackupJoin} {BackupJoin}

View file

@ -0,0 +1,121 @@
using System.Data.Common;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Performance;
/// <summary>
/// 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.
/// </summary>
public sealed class QueryStoreCollector : Collector<QueryStoreSnapshot>
{
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<Capability> RequiredCapabilities => [Capability.QueryStore];
protected override async Task<QueryStoreSnapshot> 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<QueryStoreDatabase>(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<QueryStoreDatabase> 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;
""";
}
}

View file

@ -0,0 +1,55 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// 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.
/// </summary>
public sealed record QueryStoreSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<QueryStoreDatabase> Databases { get; init; }
}
public sealed record QueryStoreDatabase
{
public required string Name { get; init; }
public required IReadOnlyList<QueryPlanStats> Plans { get; init; }
public IReadOnlyList<FailedForcedPlan> FailedForcedPlans { get; init; } = [];
/// <summary>Set when this database's Query Store could not be read.</summary>
public string? Error { get; init; }
}
/// <summary>
/// One plan of one query in one window. Durations and CPU are in microseconds, as Query Store stores them.
/// </summary>
public sealed record QueryPlanStats
{
public required long QueryId { get; init; }
public required long PlanId { get; init; }
/// <summary>True for the last 24 hours, false for the seven days before.</summary>
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; }
/// <summary>The largest per-interval standard deviation of duration (an approximation across intervals).</summary>
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;
}
/// <summary>A plan forced with sp_query_store_force_plan that SQL Server could not use.</summary>
public sealed record FailedForcedPlan(long QueryId, long PlanId, long FailureCount, string? Reason, string? QueryText);

View file

@ -45,6 +45,9 @@ public static class BuiltInRules
new LogFileWriteLatencyRule(), new LogFileWriteLatencyRule(),
new CpuRule(), new CpuRule(),
new MemoryGrantsPendingRule(), new MemoryGrantsPendingRule(),
new PlanRegressionRule(),
new FailedForcedPlanRule(),
new ParameterSensitivityRule(),
// Concurrency // Concurrency
new BlockingRule(), new BlockingRule(),

View file

@ -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");
}
/// <summary>
/// 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.
/// </summary>
public sealed class PlanRegressionRule : Rule
{
public const int MinExecutions = 10;
public const double MinSlowdown = 2.0;
/// <summary>Extra time per day the regression must cost before it is worth reporting.</summary>
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<Capability> 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<QueryStoreSnapshot>().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));
}
}
/// <summary>
/// A plan forced in Query Store that SQL Server can no longer use (e.g. an index it needs was dropped).
/// </summary>
public sealed class FailedForcedPlanRule : Rule
{
public override string Id => "SQLI-QS-002";
public override string Title => "Forceret plan kan ikke bruges";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(context.Data.Get<QueryStoreSnapshot>().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));
}
}
/// <summary>
/// A query whose duration swings wildly between executions, or that alternates between very different plans:
/// the signature of parameter sensitivity (parameter sniffing).
/// </summary>
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<Capability> 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<QueryStoreSnapshot>().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<Evidence> { 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));
}
}

View file

@ -185,6 +185,47 @@ public class CollectorTests(SqlServerFixture sql)
Assert.Empty(again.NewDeadlocks); 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<QueryStoreSnapshot>(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<DeadlockSnapshot> WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous) private static async Task<DeadlockSnapshot> WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous)
{ {
// system_health dispatches events to its targets with up to 120 seconds latency. // system_health dispatches events to its targets with up to 120 seconds latency.

View file

@ -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);
}
}