- 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>
221 lines
9.3 KiB
C#
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);
|
|
}
|
|
}
|