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(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, string platform = "Windows") => new() { CapturedAt = Now, HostPlatform = platform, 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 Other_processes_are_not_judged_on_linux() { // SystemIdle reads near 0 in Linux containers, which would look like 98 % CPU used by others. Assert.Equal(RuleOutcome.Passed, Run(new CpuRule(), With(Cpu(sql: 2, idle: 0, platform: "Linux"))).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(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 = "", 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); } }