Some checks are pending
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
CI / Build og unit tests (push) Waiting to run
Collectors (SQL Server 2000+, version-specific SQL, least privilege): server, configuration, trace flags, databases (settings, backups in UTC, CHECKDB, VLFs, Query Store, files, tempdb), suspect pages, plan cache, wait stats and file I/O as deltas with restart/clear detection, performance counters, CPU from the scheduler monitor ring buffer, blocking (sleeping head blockers with open transactions), and deadlocks from system_health continuing from the last file offset. Rules: configuration (memory, MAXDOP per Microsoft NUMA guidance, cost threshold, ad hoc bloat, risky options, pending changes), databases (state, settings, backups, log backups, CHECKDB, suspect pages, VLFs, autogrowth, max size, Query Store, tempdb), waits (THREADPOOL, signal waits, locks, I/O, WRITELOG, parallelism, PAGELATCH), I/O latency, CPU, memory grants, blocking and deadlocks. Missing data makes a rule not measurable with the collector's reason. Integration tests run every collector as the least-privilege login and reproduce real blocking and a real deadlock; green on 2017 and 2022. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
197 lines
8 KiB
C#
197 lines
8 KiB
C#
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Rules.Concurrency;
|
|
using SQLInsight.Rules.Engine;
|
|
using SQLInsight.Rules.Performance;
|
|
using static SQLInsight.Rules.Tests.TestInstances;
|
|
|
|
namespace SQLInsight.Rules.Tests;
|
|
|
|
public class PerformanceRuleTests
|
|
{
|
|
private static readonly TimeSpan FiveMinutes = TimeSpan.FromMinutes(5);
|
|
|
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
|
|
|
private static IRule Wait(string id) => WaitRules.Create().Single(r => r.Id == id);
|
|
|
|
[Fact]
|
|
public void First_wait_sample_is_not_measurable()
|
|
{
|
|
var snapshot = new WaitStatsSnapshot { CapturedAt = Now, SqlServerStartTime = Now.UtcDateTime, Waits = [] };
|
|
var context = With(new CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>(snapshot, null));
|
|
|
|
var result = Run(Wait("SQLI-WAIT-003"), context);
|
|
|
|
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
|
|
Assert.Contains("to målinger", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Threadpool_waits_are_critical()
|
|
{
|
|
var context = With(Waits(FiveMinutes, new WaitStat("THREADPOOL", 50, 25_000, 0)));
|
|
|
|
Assert.Equal(Severity.Critical, Assert.Single(Run(Wait("SQLI-WAIT-001"), context).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Lock_waits_need_both_share_and_volume()
|
|
{
|
|
// 300 s interval: 0.5 waiters = 150,000 ms.
|
|
var busy = With(Waits(FiveMinutes, new WaitStat("LCK_M_X", 100, 600_000, 100), new WaitStat("PAGEIOLATCH_SH", 1000, 100_000, 100)));
|
|
var idle = With(Waits(FiveMinutes, new WaitStat("LCK_M_X", 1, 5_000, 0))); // 100 % share, but trivial
|
|
|
|
Assert.Equal(RuleOutcome.Findings, Run(Wait("SQLI-WAIT-003"), busy).Outcome);
|
|
Assert.Equal(RuleOutcome.Passed, Run(Wait("SQLI-WAIT-003"), idle).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Heavy_lock_waits_are_high()
|
|
{
|
|
var context = With(Waits(FiveMinutes, new WaitStat("LCK_M_U", 500, 2_000_000, 0)));
|
|
|
|
Assert.Equal(Severity.High, Assert.Single(Run(Wait("SQLI-WAIT-003"), context).Findings).Severity);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(10_000, 30_000, RuleOutcome.Passed)] // 3 ms
|
|
[InlineData(10_000, 80_000, RuleOutcome.Findings)] // 8 ms
|
|
[InlineData(500, 50_000, RuleOutcome.Passed)] // too few commits to judge
|
|
public void WriteLog_average_latency(long tasks, long ms, RuleOutcome expected)
|
|
{
|
|
var context = With(Waits(FiveMinutes, new WaitStat("WRITELOG", tasks, ms, 0)));
|
|
|
|
Assert.Equal(expected, Run(Wait("SQLI-WAIT-005"), context).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void High_signal_wait_ratio_means_cpu_pressure()
|
|
{
|
|
var context = With(Waits(FiveMinutes, new WaitStat("SOS_SCHEDULER_YIELD", 100_000, 400_000, 400_000), new WaitStat("PAGEIOLATCH_SH", 1000, 400_000, 10_000)));
|
|
|
|
Assert.Equal(Severity.High, Assert.Single(Run(Wait("SQLI-WAIT-002"), context).Findings).Severity);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ROWS", 5000, 50_000, 0, 0, RuleOutcome.Passed)] // 10 ms
|
|
[InlineData("ROWS", 5000, 150_000, 0, 0, RuleOutcome.Findings)] // 30 ms
|
|
[InlineData("ROWS", 500, 150_000, 0, 0, RuleOutcome.Passed)] // too few reads
|
|
public void Data_file_read_latency(string type, long reads, long stall, long writes, long writeStall, RuleOutcome expected)
|
|
{
|
|
Assert.Equal(expected, Run(new DataFileReadLatencyRule(), With(Io(File(type, reads, stall, writes, writeStall)))).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(10_000, 20_000, RuleOutcome.Passed)] // 2 ms
|
|
[InlineData(10_000, 200_000, RuleOutcome.Findings)] // 20 ms
|
|
public void Log_write_latency(long writes, long stall, RuleOutcome expected)
|
|
{
|
|
var result = Run(new LogFileWriteLatencyRule(), With(Io(File("LOG", writes: writes, writeStallMs: stall))));
|
|
|
|
Assert.Equal(expected, result.Outcome);
|
|
if (expected == RuleOutcome.Findings)
|
|
{
|
|
Assert.Equal(Severity.High, Assert.Single(result.Findings).Severity);
|
|
}
|
|
}
|
|
|
|
private static CpuUtilization Cpu(int sql, int idle, int minutes = 15) => new()
|
|
{
|
|
CapturedAt = Now,
|
|
Samples = Enumerable.Range(0, minutes).Select(i => new CpuSample(Now.AddMinutes(i - minutes), sql, idle)).ToList(),
|
|
};
|
|
|
|
[Fact]
|
|
public void Cpu_distinguishes_sql_server_from_other_processes()
|
|
{
|
|
Assert.Equal(["cpu: sqlserver"], Run(new CpuRule(), With(Cpu(sql: 92, idle: 5))).Findings.Select(f => f.Subject));
|
|
Assert.Equal(["cpu: other"], Run(new CpuRule(), With(Cpu(sql: 20, idle: 40))).Findings.Select(f => f.Subject));
|
|
Assert.Equal(RuleOutcome.Passed, Run(new CpuRule(), With(Cpu(sql: 30, idle: 65))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cpu_needs_enough_history()
|
|
{
|
|
Assert.Equal(RuleOutcome.NotMeasurable, Run(new CpuRule(), With(Cpu(sql: 99, idle: 0, minutes: 3))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Memory_grants_pending()
|
|
{
|
|
var counters = new PerformanceCounterSnapshot { CapturedAt = Now, SqlServerStartTime = Now.UtcDateTime, MemoryGrantsPending = 3, MemoryGrantsOutstanding = 20 };
|
|
|
|
var finding = Assert.Single(Run(new MemoryGrantsPendingRule(), With(new CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>(counters, null))).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
}
|
|
|
|
private static SessionInfo Session(int id, int? blockedBy = null, string status = "suspended", long waitMs = 0, int openTran = 0) => new()
|
|
{
|
|
SessionId = id,
|
|
BlockingSessionId = blockedBy,
|
|
Status = status,
|
|
WaitType = blockedBy is null ? null : "LCK_M_S",
|
|
WaitTimeMs = waitMs,
|
|
WaitResource = "KEY: 5:72057594043236352 (8194443284a0)",
|
|
OpenTransactionCount = openTran,
|
|
SqlText = $"statement {id}",
|
|
};
|
|
|
|
[Fact]
|
|
public void Sleeping_head_blocker_with_open_transaction_gets_specific_advice()
|
|
{
|
|
var snapshot = new BlockingSnapshot
|
|
{
|
|
CapturedAt = Now,
|
|
Sessions =
|
|
[
|
|
Session(51, status: "sleeping", openTran: 1),
|
|
Session(60, blockedBy: 51, waitMs: 45_000),
|
|
Session(61, blockedBy: 60, waitMs: 40_000), // chained behind 60
|
|
],
|
|
};
|
|
|
|
var finding = Assert.Single(Run(new BlockingRule(), With(snapshot)).Findings);
|
|
|
|
Assert.Equal("session: 51", finding.Subject);
|
|
Assert.Contains("åben transaktion", finding.Title, StringComparison.Ordinal);
|
|
Assert.Contains(finding.Evidence, e => e.Name == "Blokerede sessioner" && e.Value == "2");
|
|
Assert.Contains("XACT_ABORT", finding.Recommendation, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Short_blocking_is_normal()
|
|
{
|
|
var snapshot = new BlockingSnapshot { CapturedAt = Now, Sessions = [Session(51, status: "running"), Session(60, blockedBy: 51, waitMs: 2_000)] };
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new BlockingRule(), With(snapshot)).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deadlocks_report_the_processes()
|
|
{
|
|
var deadlock = new DeadlockEvent
|
|
{
|
|
Time = Now,
|
|
GraphXml = "<deadlock/>",
|
|
Processes =
|
|
[
|
|
new DeadlockProcess { Id = "p1", IsVictim = true, SessionId = 60, LockMode = "U", WaitResource = "KEY: 5:1", InputBuffer = "UPDATE a" },
|
|
new DeadlockProcess { Id = "p2", SessionId = 61, LockMode = "X", WaitResource = "KEY: 5:2", InputBuffer = "UPDATE b" },
|
|
],
|
|
};
|
|
var snapshot = new DeadlockSnapshot { CapturedAt = Now, NewDeadlocks = [deadlock] };
|
|
|
|
var finding = Assert.Single(Run(new DeadlockRule(), With(snapshot)).Findings);
|
|
|
|
Assert.Equal(Severity.Medium, finding.Severity);
|
|
Assert.Contains(finding.Evidence, e => e.Name.Contains("offer session 60", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void No_new_deadlocks_passes()
|
|
{
|
|
Assert.Equal(RuleOutcome.Passed, Run(new DeadlockRule(), With(new DeadlockSnapshot { CapturedAt = Now, NewDeadlocks = [] })).Outcome);
|
|
}
|
|
}
|