sqlinsight-pro/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs
Søren Eggert Lundsteen Olsen ac13e3d826 Disk speed per drive: IOPS, MB/s and latency
- file-io records each file's drive: sys.dm_os_volume_stats from 2008 R2 SP1
  (new capability VolumeStats, mount points included), otherwise derived
  from the path (D:\..., \server\share\, / on Linux), so 2005 works too
- migration 0007: Volume and IntervalSeconds on FileIoSamples, and
  DiskHourly (rolled up by maintenance, kept HourlyDays)
- DiskActivity sums files per drive: read/write IOPS, MB/s and latency
  weighted by I/O count
- instance page: "Diskhastighed pr. drev" (table and MB/s + latency charts);
  time window analysis and its PDF: per drive against the same hours of the
  four previous weeks
- SQLI-IO-001/002 now report per drive (one finding when the storage is
  slow, the slowest files in the evidence) instead of per file

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 08:03:11 +02:00

221 lines
9.3 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);
}
}
[Fact]
public void Slow_data_files_on_one_drive_give_one_finding_for_the_drive()
{
var sales = File("ROWS", reads: 3000, readStallMs: 90_000); // 30 ms
var stock = File("ROWS", reads: 2000, readStallMs: 40_000) with { DatabaseName = "Stock", FileId = 1, PhysicalName = @"D:\Stock.mdf" };
var fast = File("ROWS", reads: 5000, readStallMs: 5_000) with { DatabaseName = "Fast", PhysicalName = @"E:\Fast.mdf" };
var result = Run(new DataFileReadLatencyRule(), With(Io(sales, stock, fast)));
var finding = Assert.Single(result.Findings);
Assert.Equal(@"volume: D:\ data", finding.Subject);
Assert.Contains(@"D:\ (datafiler): 26,0 ms", finding.Title, StringComparison.Ordinal); // 130 s over 5000 reads
Assert.Contains(finding.Evidence, e => e.Name == "Langsomste filer" && e.Value.StartsWith("Sales 30,0 ms", StringComparison.Ordinal));
Assert.Contains(finding.Evidence, e => e.Name == "Læse-IOPS");
}
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<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);
}
}