sqlinsight-pro/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs
Claude 5bb1c37fef
Some checks are pending
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:2017-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
Fase 1: on-prem web UI (IIS or Windows service), light design system
- ASP.NET Core Blazor static SSR: overview with KPI tiles, instance page
  (findings, CPU/batch/PLE trends, top waits, file latency, blocking,
  deadlocks with .xdl download, not-measurable rules, resolved), finding
  detail with explanation, evidence, fix script and references
- Light-first design (sidebar, cards, self-hosted Inter, teal accent),
  optional dark theme via toggle; strict CSP, no inline styles
- Windows authentication (Negotiate/IIS) with optional group restriction;
  refuses to run without authentication outside Development
- Hosting: IIS (in-process ASP.NET Core Module), Windows service with
  Kestrel, or systemd; web reads the repository with db_datareader only
- docs/installation.md for repository, permissions, agent and web
- Fix: CPU used by other processes is not derived on Linux (SystemIdle is
  unreliable there); deadlocks are not re-reported or stored twice after
  an agent restart; Dapper sends datetime2 so time comparisons are exact

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 06:58:51 +00:00

205 lines
8.4 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, 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);
}
}