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
- 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
205 lines
8.4 KiB
C#
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);
|
|
}
|
|
}
|