Fase 1: collectors and rule pack (41 rules)
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
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:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run

Collectors (SQL Server 2000+, version-specific SQL, least privilege):
server, configuration, trace flags, databases (settings, backups in UTC,
CHECKDB, VLFs, Query Store, files, tempdb), suspect pages, plan cache,
wait stats and file I/O as deltas with restart/clear detection,
performance counters, CPU from the scheduler monitor ring buffer,
blocking (sleeping head blockers with open transactions), and deadlocks
from system_health continuing from the last file offset.

Rules: configuration (memory, MAXDOP per Microsoft NUMA guidance, cost
threshold, ad hoc bloat, risky options, pending changes), databases
(state, settings, backups, log backups, CHECKDB, suspect pages, VLFs,
autogrowth, max size, Query Store, tempdb), waits (THREADPOOL, signal
waits, locks, I/O, WRITELOG, parallelism, PAGELATCH), I/O latency, CPU,
memory grants, blocking and deadlocks. Missing data makes a rule not
measurable with the collector's reason.

Integration tests run every collector as the least-privilege login and
reproduce real blocking and a real deadlock; green on 2017 and 2022.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 06:17:08 +00:00
parent 2a950ed4e9
commit 63f23c4bbd
No known key found for this signature in database
58 changed files with 4600 additions and 53 deletions

View file

@ -8,8 +8,8 @@
<AnalysisLevel>latest-recommended</AnalysisLevel> <AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- CS1591: missing XML comments. Public API docs are wanted, but not enforced on every member. --> <!-- CS1591/CS1573: missing XML comments. Public API docs are wanted, but not enforced on every member or parameter. -->
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591;CS1573</NoWarn>
<Company>SEOSoft</Company> <Company>SEOSoft</Company>
<Product>SQLInsight Pro</Product> <Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright> <Copyright>Copyright (c) SEOSoft</Copyright>

View file

@ -1,6 +1,7 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/SQLInsight.Agent/SQLInsight.Agent.csproj" /> <Project Path="src/SQLInsight.Agent/SQLInsight.Agent.csproj" />
<Project Path="src/SQLInsight.Collectors/SQLInsight.Collectors.csproj" />
<Project Path="src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj" /> <Project Path="src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj" />
<Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" /> <Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" />
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" /> <Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
@ -9,6 +10,7 @@
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" /> <Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj" />
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" /> <Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" /> <Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
<Project Path="tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj" /> <Project Path="tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj" />

View file

@ -0,0 +1,25 @@
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Collectors.Databases;
using SQLInsight.Collectors.Instance;
using SQLInsight.Collectors.Performance;
namespace SQLInsight.Collectors;
public static class BuiltInCollectors
{
public static IReadOnlyList<ICollector> Create() =>
[
new ServerCollector(),
new ConfigurationCollector(),
new TraceFlagCollector(),
new DatabaseCollector(),
new SuspectPageCollector(),
new PlanCacheCollector(),
new WaitStatsCollector(),
new FileIoCollector(),
new PerformanceCounterCollector(),
new CpuCollector(),
new BlockingCollector(),
new DeadlockCollector(),
];
}

View file

@ -0,0 +1,79 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Concurrency;
/// <summary>
/// Blocked sessions and their blockers right now. Head blockers are often sleeping sessions with an open
/// transaction, which have no row in <c>sys.dm_exec_requests</c>, so sessions are the starting point.
/// </summary>
public sealed class BlockingCollector : Collector<BlockingSnapshot>
{
public override string Name => "blocking";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1);
protected override async Task<BlockingSnapshot> CollectAsync(CollectorContext context, BlockingSnapshot? previous)
{
var version = context.Instance.Version;
var sessions = version.Major >= 9
? await Db.QueryAsync(context.Connection, BuildSql2005(version), Map2005, context.CancellationToken).ConfigureAwait(false)
: await Db.QueryAsync(context.Connection, Sql2000, Map2000, context.CancellationToken).ConfigureAwait(false);
return new BlockingSnapshot { CapturedAt = context.Now, Sessions = sessions };
}
internal static string BuildSql2005(SqlServerVersion version)
{
var openTran = version.Major >= 11
? "s.open_transaction_count"
: "(SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id)";
var database = version.Major >= 11 ? "COALESCE(r.database_id, s.database_id)" : "r.database_id";
// blocking_session_id < 0 are special cases (orphaned DTC, deferred recovery, latch) and not a session.
return $"""
SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
DB_NAME({database}), s.login_name, s.host_name, s.program_name, {openTran},
LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1)
ELSE t.text END, 4000),
s.last_request_end_time
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
WHERE r.blocking_session_id > 0
OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
""";
}
// blocked = spid is a session waiting on its own latch, not blocking.
internal const string Sql2000 = """
SELECT p.spid, NULLIF(CASE WHEN p.blocked = p.spid THEN 0 ELSE p.blocked END, 0), RTRIM(p.status), RTRIM(p.lastwaittype),
p.waittime, RTRIM(p.waitresource), DB_NAME(p.dbid), RTRIM(p.loginame), RTRIM(p.hostname), RTRIM(p.program_name),
p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch
FROM master.dbo.sysprocesses p
WHERE (p.blocked > 0 AND p.blocked <> p.spid)
OR p.spid IN (SELECT blocked FROM master.dbo.sysprocesses WHERE blocked > 0 AND blocked <> spid)
""";
private static SessionInfo Map2005(Row r) => new()
{
SessionId = r.Int(0),
BlockingSessionId = r.NullableInt(1),
Status = r.NullableString(2),
WaitType = r.NullableString(3),
WaitTimeMs = r.NullableLong(4),
WaitResource = r.NullableString(5),
DatabaseName = r.NullableString(6),
LoginName = r.NullableString(7),
HostName = r.NullableString(8),
ProgramName = r.NullableString(9),
OpenTransactionCount = r.NullableInt(10) ?? 0,
SqlText = r.NullableString(11),
LastRequestEndTime = r.NullableDateTime(12),
};
private static SessionInfo Map2000(Row r) => Map2005(r);
}

View file

@ -0,0 +1,192 @@
using System.Data.Common;
using System.Globalization;
using System.Xml.Linq;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Concurrency;
/// <summary>
/// Reads new deadlock graphs from the built-in <c>system_health</c> session. From 2012 the session writes to
/// files (up to 1 GB on 2022), so reading continues from the last file and offset instead of rescanning.
/// system_health dispatches events with up to 120 seconds latency, so a deadlock can show up one collection late.
/// </summary>
public sealed class DeadlockCollector : Collector<DeadlockSnapshot>
{
/// <summary>On the first run, only deadlocks this recent are reported.</summary>
public static readonly TimeSpan InitialLookback = TimeSpan.FromDays(1);
public override string Name => "deadlocks";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SystemHealthSession];
protected override async Task<DeadlockSnapshot> CollectAsync(CollectorContext context, DeadlockSnapshot? previous)
{
var since = previous?.LastEventTime ?? context.Now - InitialLookback;
if (!context.Capabilities.Has(Capability.SystemHealthFileTarget))
{
return await ReadRingBufferAsync(context, since).ConfigureAwait(false);
}
try
{
return await ReadFilesAsync(context, since, previous?.LastFileName, previous?.LastFileOffset).ConfigureAwait(false);
}
catch (DbException) when (previous?.LastFileName is not null)
{
// The file we stopped in has rolled over and been deleted. Read everything once more.
return await ReadFilesAsync(context, since, null, null).ConfigureAwait(false);
}
}
internal static string BuildFileSql(string? lastFile, long? lastOffset)
{
var start = lastFile is not null && lastOffset is not null
? $"{Db.Literal(lastFile)}, {lastOffset.Value.ToString(CultureInfo.InvariantCulture)}"
: "NULL, NULL";
// Only the deadlock rows carry their XML; the rest are kept just to know where reading stopped.
return $"""
SELECT object_name, file_name, file_offset,
CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data
INTO #system_health
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, {start});
SELECT event_data FROM #system_health WHERE event_data IS NOT NULL;
SELECT TOP (1) file_name, file_offset FROM #system_health ORDER BY file_name DESC, file_offset DESC;
DROP TABLE #system_health;
""";
}
internal const string RingBufferSql = """
SELECT CAST(t.target_data AS nvarchar(max))
FROM sys.dm_xe_session_targets t
JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
WHERE s.name = N'system_health' AND t.target_name = N'ring_buffer'
""";
private static async Task<DeadlockSnapshot> ReadFilesAsync(CollectorContext context, DateTimeOffset since, string? lastFile, long? lastOffset)
{
var ct = context.CancellationToken;
await using var command = context.Connection.CreateCommand();
command.CommandText = BuildFileSql(lastFile, lastOffset);
command.CommandTimeout = 120;
var events = new List<string>();
string? file = lastFile;
long? offset = lastOffset;
await using (var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false))
{
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
events.Add(reader.GetString(0));
}
if (await reader.NextResultAsync(ct).ConfigureAwait(false) && await reader.ReadAsync(ct).ConfigureAwait(false))
{
file = reader.GetString(0);
offset = Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture);
}
}
var deadlocks = events
.Select(e => DeadlockParser.ParseEvent(XElement.Parse(e)))
.OfType<DeadlockEvent>()
.Where(d => d.Time > since)
.OrderBy(d => d.Time)
.ToList();
return new DeadlockSnapshot
{
CapturedAt = context.Now,
NewDeadlocks = deadlocks,
LastEventTime = deadlocks.Count > 0 ? deadlocks[^1].Time : since,
LastFileName = file,
LastFileOffset = offset,
};
}
private static async Task<DeadlockSnapshot> ReadRingBufferAsync(CollectorContext context, DateTimeOffset since)
{
var rows = await Db.QueryAsync(context.Connection, RingBufferSql, r => r.NullableString(0), context.CancellationToken).ConfigureAwait(false);
var deadlocks = new List<DeadlockEvent>();
if (rows.FirstOrDefault() is { } xml)
{
deadlocks = XElement.Parse(xml).Elements("event")
.Where(e => (string?)e.Attribute("name") == "xml_deadlock_report")
.Select(DeadlockParser.ParseEvent)
.OfType<DeadlockEvent>()
.Where(d => d.Time > since)
.OrderBy(d => d.Time)
.ToList();
}
return new DeadlockSnapshot
{
CapturedAt = context.Now,
NewDeadlocks = deadlocks,
LastEventTime = deadlocks.Count > 0 ? deadlocks[^1].Time : since,
};
}
}
/// <summary>
/// Turns an <c>xml_deadlock_report</c> event into a <see cref="DeadlockEvent"/>.
/// </summary>
internal static class DeadlockParser
{
public static DeadlockEvent? ParseEvent(XElement evt)
{
var timestamp = (string?)evt.Attribute("timestamp");
var value = evt.Elements("data").FirstOrDefault(d => (string?)d.Attribute("name") == "xml_report")?.Element("value");
if (timestamp is null || value is null)
{
return null;
}
// SQL Server 2008 RTM–SP1 wrote the graph as escaped text instead of XML.
var deadlock = value.Element("deadlock") ?? TryParse(value.Value)?.DescendantsAndSelf("deadlock").FirstOrDefault();
if (deadlock is null)
{
return null;
}
var victims = deadlock.Element("victim-list")?.Elements("victimProcess").Select(v => (string?)v.Attribute("id")).ToHashSet()
?? [(string?)deadlock.Attribute("victim")];
var processes = deadlock.Element("process-list")?.Elements("process").Select(p => new DeadlockProcess
{
Id = (string?)p.Attribute("id") ?? "?",
IsVictim = victims.Contains((string?)p.Attribute("id")),
SessionId = (int?)p.Attribute("spid"),
DatabaseName = (string?)p.Attribute("currentdbname") ?? (string?)p.Attribute("currentdb"),
LoginName = (string?)p.Attribute("loginname"),
HostName = (string?)p.Attribute("hostname"),
ClientApp = (string?)p.Attribute("clientapp"),
IsolationLevel = (string?)p.Attribute("isolationlevel"),
WaitResource = (string?)p.Attribute("waitresource"),
LockMode = (string?)p.Attribute("lockMode"),
InputBuffer = p.Element("inputbuf")?.Value.Trim(),
}).ToList() ?? [];
return new DeadlockEvent
{
Time = DateTimeOffset.Parse(timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
Processes = processes,
GraphXml = deadlock.ToString(SaveOptions.DisableFormatting),
};
}
private static XElement? TryParse(string text)
{
try
{
return XElement.Parse(text);
}
catch (System.Xml.XmlException)
{
return null;
}
}
}

View file

@ -0,0 +1,223 @@
using System.Data.Common;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Databases;
/// <summary>
/// Settings, backup history, CHECKDB status, VLFs, Query Store state and files per database.
/// </summary>
public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
{
// Backups of an earlier database with the same name (dropped and recreated) must not count, hence the
// create_date comparison.
private const string BackupJoin = """
LEFT JOIN (
SELECT database_name,
MAX(CASE WHEN type = 'D' THEN backup_finish_date END) AS last_full,
MAX(CASE WHEN type = 'I' THEN backup_finish_date END) AS last_diff,
MAX(CASE WHEN type = 'L' THEN backup_finish_date END) AS last_log,
MIN(backup_finish_date) AS first_backup
FROM msdb.dbo.backupset
GROUP BY database_name
) b ON b.database_name = d.name
""";
public override string Name => "databases";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
protected override async Task<DatabaseSnapshot> CollectAsync(CollectorContext context, DatabaseSnapshot? previous)
{
var connection = context.Connection;
var ct = context.CancellationToken;
var version = context.Instance.Version;
List<DatabaseInfo> databases;
List<(int DatabaseId, DatabaseFileInfo File)> files;
if (version.Major >= 9)
{
databases = await Db.QueryAsync(connection, BuildSql2005(context.Instance, context.Capabilities), Map2005, ct).ConfigureAwait(false);
files = await Db.QueryAsync(connection, Files2005, MapFile, ct).ConfigureAwait(false);
}
else
{
databases = await Db.QueryAsync(connection, BuildSql2000(), Map2000, ct).ConfigureAwait(false);
files = await Db.QueryAsync(connection, Files2000, MapFile, ct).ConfigureAwait(false);
}
// msdb and DATABASEPROPERTYEX report server local time; everything downstream works in UTC.
var offsetMinutes = (await Db.QueryAsync(connection, "SELECT DATEDIFF(minute, GETUTCDATE(), GETDATE())", r => r.Int(0), ct).ConfigureAwait(false))[0];
DateTime? ToUtc(DateTime? local) => local is { Year: > 1900 } value ? DateTime.SpecifyKind(value.AddMinutes(-offsetMinutes), DateTimeKind.Utc) : local;
var vlfs = context.Capabilities.Has(Capability.LogInfoDmv)
? (await Db.QueryAsync(connection, VlfSql, r => (Id: r.Int(0), Count: r.Int(1)), ct).ConfigureAwait(false)).ToDictionary(v => v.Id, v => v.Count)
: [];
var result = new List<DatabaseInfo>(databases.Count);
foreach (var database in databases)
{
var queryStore = context.Capabilities.Has(Capability.QueryStore) && ShouldReadQueryStore(database)
? await ReadQueryStoreAsync(connection, database.Name, ct).ConfigureAwait(false)
: null;
result.Add(database with
{
CreateDate = ToUtc(database.CreateDate),
LastFullBackup = ToUtc(database.LastFullBackup),
LastDifferentialBackup = ToUtc(database.LastDifferentialBackup),
LastLogBackup = ToUtc(database.LastLogBackup),
LastGoodCheckDb = ToUtc(database.LastGoodCheckDb),
Files = files.Where(f => f.DatabaseId == database.DatabaseId).Select(f => f.File).ToList(),
VirtualLogFileCount = vlfs.TryGetValue(database.DatabaseId, out var count) ? count : null,
QueryStore = queryStore,
});
}
return new DatabaseSnapshot
{
CapturedAt = context.Now,
Databases = result,
TempdbFiles = files.Where(f => f.DatabaseId == 2).Select(f => f.File).ToList(),
};
}
internal static string BuildSql2005(InstanceInfo instance, InstanceCapabilities capabilities)
{
var version = instance.Version;
var checkDb = capabilities.Has(Capability.LastGoodCheckDbTime)
? "CAST(DATABASEPROPERTYEX(d.name, 'LastGoodCheckDbTime') AS datetime)"
: "CAST(NULL AS datetime)";
var inAg = version.Major >= 11 ? "CASE WHEN d.replica_id IS NOT NULL THEN 1 ELSE 0 END" : "0";
var agSecondary = version.Major >= 11
? """
OR EXISTS (SELECT 1 FROM sys.dm_hadr_database_replica_states rs
JOIN sys.dm_hadr_availability_replica_states ars ON ars.replica_id = rs.replica_id
WHERE rs.database_id = d.database_id AND rs.is_local = 1 AND ars.role = 2)
"""
: string.Empty;
return $"""
SELECT d.name, d.database_id, d.state_desc, d.recovery_model_desc, d.compatibility_level, d.page_verify_option_desc,
d.is_auto_close_on, d.is_auto_shrink_on, d.is_read_only, d.is_trustworthy_on, d.create_date,
CASE WHEN EXISTS (SELECT 1 FROM sys.database_mirroring m WHERE m.database_id = d.database_id AND m.mirroring_role = 2)
{agSecondary}
THEN 1 ELSE 0 END,
{inAg},
CASE WHEN b.last_full >= d.create_date THEN b.last_full END,
CASE WHEN b.last_diff >= d.create_date THEN b.last_diff END,
CASE WHEN b.last_log >= d.create_date THEN b.last_log END,
{checkDb}
FROM sys.databases d
{BackupJoin}
WHERE d.name <> N'tempdb'
""";
}
internal static string BuildSql2000() => $"""
SELECT d.name, d.dbid,
CAST(DATABASEPROPERTYEX(d.name, 'Status') AS nvarchar(60)),
CAST(DATABASEPROPERTYEX(d.name, 'Recovery') AS nvarchar(60)),
d.cmptlevel,
CASE WHEN DATABASEPROPERTYEX(d.name, 'IsTornPageDetectionEnabled') = 1 THEN N'TORN_PAGE_DETECTION' ELSE N'NONE' END,
CAST(DATABASEPROPERTYEX(d.name, 'IsAutoClose') AS int),
CAST(DATABASEPROPERTYEX(d.name, 'IsAutoShrink') AS int),
CASE WHEN DATABASEPROPERTYEX(d.name, 'Updateability') = 'READ_ONLY' THEN 1 ELSE 0 END,
0,
d.crdate,
0,
0,
CASE WHEN b.last_full >= d.crdate THEN b.last_full END,
CASE WHEN b.last_diff >= d.crdate THEN b.last_diff END,
CASE WHEN b.last_log >= d.crdate THEN b.last_log END,
CAST(NULL AS datetime)
FROM master.dbo.sysdatabases d
{BackupJoin}
WHERE d.name <> N'tempdb'
""";
internal const string Files2005 = """
SELECT database_id, name, type_desc, physical_name,
CAST(size AS bigint) / 128,
CASE WHEN is_percent_growth = 1 THEN growth ELSE CAST(growth AS bigint) / 128 END,
is_percent_growth,
CASE WHEN max_size = -1 THEN -1 ELSE CAST(max_size AS bigint) / 128 END
FROM sys.master_files
WHERE type_desc IN (N'ROWS', N'LOG')
""";
// sysaltfiles.status 0x100000 = growth in percent; groupid 0 = log file.
internal const string Files2000 = """
SELECT dbid, name, CASE WHEN groupid = 0 THEN N'LOG' ELSE N'ROWS' END, filename,
CAST(size AS bigint) / 128,
CASE WHEN status & 0x100000 <> 0 THEN growth ELSE CAST(growth AS bigint) / 128 END,
CASE WHEN status & 0x100000 <> 0 THEN 1 ELSE 0 END,
CASE WHEN maxsize = -1 THEN -1 ELSE CAST(maxsize AS bigint) / 128 END
FROM master.dbo.sysaltfiles
""";
internal const string VlfSql = """
SELECT d.database_id, COUNT(*)
FROM sys.databases d
CROSS APPLY sys.dm_db_log_info(d.database_id) li
WHERE d.state = 0
GROUP BY d.database_id
""";
private static DatabaseInfo Map2005(Row r) => new()
{
Name = r.String(0),
DatabaseId = r.Int(1),
State = r.String(2),
RecoveryModel = r.String(3),
CompatibilityLevel = r.Int(4),
PageVerify = r.NullableString(5),
IsAutoClose = r.Bool(6),
IsAutoShrink = r.Bool(7),
IsReadOnly = r.Bool(8),
IsTrustworthy = r.Bool(9),
CreateDate = r.NullableDateTime(10),
IsReplicaOrMirror = r.Bool(11),
IsInAvailabilityGroup = r.Bool(12),
LastFullBackup = r.NullableDateTime(13),
LastDifferentialBackup = r.NullableDateTime(14),
LastLogBackup = r.NullableDateTime(15),
LastGoodCheckDb = r.NullableDateTime(16),
};
private static DatabaseInfo Map2000(Row r) => Map2005(r) with { State = r.NullableString(2) ?? "UNKNOWN", RecoveryModel = r.NullableString(3) ?? "UNKNOWN" };
private static (int, DatabaseFileInfo) MapFile(Row r) => (r.Int(0), new DatabaseFileInfo
{
LogicalName = r.String(1),
Type = r.String(2),
PhysicalName = r.String(3),
SizeMb = r.Long(4),
Growth = r.Long(5),
IsPercentGrowth = r.Bool(6),
MaxSizeMb = r.Long(7),
});
private static bool ShouldReadQueryStore(DatabaseInfo database) =>
database.State == "ONLINE" && !database.IsSystemDatabase && !database.IsReplicaOrMirror;
private static async Task<QueryStoreState?> ReadQueryStoreAsync(DbConnection connection, string database, CancellationToken ct)
{
var sql = $"""
SELECT desired_state_desc, actual_state_desc, readonly_reason, current_storage_size_mb, max_storage_size_mb
FROM {Db.Identifier(database)}.sys.database_query_store_options
""";
try
{
return await Db.QuerySingleAsync(
connection,
sql,
r => new QueryStoreState(r.String(0), r.String(1), r.Int(2), r.Long(3), r.Long(4)),
ct).ConfigureAwait(false);
}
catch (DbException)
{
// No access to this database (e.g. the agent has no user in it on pre-2014 instances). Leave it unknown.
return null;
}
}
}

View file

@ -0,0 +1,89 @@
using System.Data.Common;
using System.Globalization;
namespace SQLInsight.Collectors;
/// <summary>
/// Minimal, provider-neutral query helpers. Works the same over SqlClient and ODBC, so collectors never use
/// named parameters (ODBC only knows positional <c>?</c>); the few literals needed are escaped with <see cref="Literal"/>.
/// </summary>
internal static class Db
{
public const int CommandTimeoutSeconds = 30;
public static async Task<List<T>> QueryAsync<T>(DbConnection connection, string sql, Func<Row, T> map, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = CommandTimeoutSeconds;
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var rows = new List<T>();
var row = new Row(reader);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
rows.Add(map(row));
}
return rows;
}
public static async Task<T?> QuerySingleAsync<T>(DbConnection connection, string sql, Func<Row, T> map, CancellationToken cancellationToken)
where T : class
{
var rows = await QueryAsync(connection, sql, map, cancellationToken).ConfigureAwait(false);
return rows.Count > 0 ? rows[0] : null;
}
/// <summary>An N'…' string literal with quotes escaped.</summary>
public static string Literal(string value) => "N'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
/// <summary>A [bracketed] identifier with brackets escaped.</summary>
public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
/// <summary>
/// The SQL expression for when the instance started. Cumulative DMVs reset at that moment.
/// </summary>
public static string StartTimeExpression(Core.Instances.SqlServerVersion version) => version.Major switch
{
>= 10 => "(SELECT sqlserver_start_time FROM sys.dm_os_sys_info)",
9 => "(SELECT create_date FROM sys.databases WHERE database_id = 2)",
_ => "(SELECT login_time FROM master.dbo.sysprocesses WHERE spid = 1)",
};
public static async Task<DateTime> StartTimeAsync(DbConnection connection, Core.Instances.SqlServerVersion version, CancellationToken cancellationToken)
{
var rows = await QueryAsync(connection, "SELECT " + StartTimeExpression(version), r => r.DateTime(0), cancellationToken).ConfigureAwait(false);
return rows[0];
}
}
/// <summary>
/// Type-tolerant access to a data reader row. DMV column types differ between versions (int vs bigint,
/// nchar vs nvarchar), so values are converted rather than cast.
/// </summary>
internal sealed class Row(DbDataReader reader)
{
public bool IsNull(int ordinal) => reader.IsDBNull(ordinal);
public string String(int ordinal) => Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture)!.TrimEnd();
public string? NullableString(int ordinal) => reader.IsDBNull(ordinal) ? null : String(ordinal);
public long Long(int ordinal) => Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
public long? NullableLong(int ordinal) => reader.IsDBNull(ordinal) ? null : Long(ordinal);
public int Int(int ordinal) => Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
public int? NullableInt(int ordinal) => reader.IsDBNull(ordinal) ? null : Int(ordinal);
public double Double(int ordinal) => Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
public bool Bool(int ordinal) => !reader.IsDBNull(ordinal) && Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0;
public DateTime DateTime(int ordinal) => Convert.ToDateTime(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
public DateTime? NullableDateTime(int ordinal) => reader.IsDBNull(ordinal) ? null : DateTime(ordinal);
public int FieldCount => reader.FieldCount;
}

View file

@ -0,0 +1,49 @@
using System.Data.Common;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors;
/// <summary>
/// Reads one kind of data from a monitored instance. Collectors are stateless; anything they need from the
/// previous run (to compute deltas or continue reading) is passed in <see cref="CollectorContext.Previous"/>.
/// </summary>
public interface ICollector
{
string Name { get; }
/// <summary>The type of the collected data, as rules look it up in <c>CollectedData</c>.</summary>
Type DataType { get; }
TimeSpan DefaultInterval { get; }
/// <summary>What the instance must support. If anything is missing, the collector does not run.</summary>
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
Task<object> CollectAsync(CollectorContext context);
}
/// <param name="Previous">This collector's result from the previous run on the same instance, if any.</param>
public sealed record CollectorContext(
DbConnection Connection,
InstanceInfo Instance,
InstanceCapabilities Capabilities,
DateTimeOffset Now,
object? Previous,
CancellationToken CancellationToken);
/// <summary>
/// Typed base class for collectors.
/// </summary>
public abstract class Collector<T> : ICollector
where T : class
{
public abstract string Name { get; }
public Type DataType => typeof(T);
public abstract TimeSpan DefaultInterval { get; }
public virtual IReadOnlyCollection<Capability> RequiredCapabilities => [];
async Task<object> ICollector.CollectAsync(CollectorContext context) =>
await CollectAsync(context, context.Previous as T).ConfigureAwait(false);
protected abstract Task<T> CollectAsync(CollectorContext context, T? previous);
}

View file

@ -0,0 +1,29 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Collectors.Instance;
public sealed class ConfigurationCollector : Collector<ConfigurationSnapshot>
{
internal const string Sql2005 =
"SELECT name, CAST(value AS bigint), CAST(value_in_use AS bigint) FROM sys.configurations";
// What sp_configure itself reads on SQL Server 2000.
internal const string Sql2000 = """
SELECT v.name, CAST(c.value AS bigint), CAST(r.value AS bigint)
FROM master.dbo.spt_values v
JOIN master.dbo.sysconfigures c ON c.config = v.number
JOIN master.dbo.syscurconfigs r ON r.config = v.number
WHERE v.type = 'C'
""";
public override string Name => "configuration";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
protected override async Task<ConfigurationSnapshot> CollectAsync(CollectorContext context, ConfigurationSnapshot? previous)
{
var sql = context.Instance.Version.Major >= 9 ? Sql2005 : Sql2000;
var options = await Db.QueryAsync(context.Connection, sql, r => new ConfigurationOption(r.String(0), r.Long(1), r.Long(2)), context.CancellationToken)
.ConfigureAwait(false);
return new ConfigurationSnapshot { CapturedAt = context.Now, Options = options };
}
}

View file

@ -0,0 +1,33 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
public sealed class PlanCacheCollector : Collector<PlanCacheSnapshot>
{
internal const string Sql = """
SELECT
COUNT_BIG(*),
ISNULL(SUM(CAST(size_in_bytes AS bigint)), 0) / 1048576.0,
ISNULL(SUM(CASE WHEN objtype = N'Adhoc' AND usecounts = 1 THEN 1 ELSE 0 END), 0),
ISNULL(SUM(CASE WHEN objtype = N'Adhoc' AND usecounts = 1 THEN CAST(size_in_bytes AS bigint) ELSE 0 END), 0) / 1048576.0
FROM sys.dm_exec_cached_plans
""";
public override string Name => "plan-cache";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<PlanCacheSnapshot> CollectAsync(CollectorContext context, PlanCacheSnapshot? previous)
{
var snapshot = await Db.QuerySingleAsync(context.Connection, Sql, r => new PlanCacheSnapshot
{
CapturedAt = context.Now,
TotalPlans = r.Long(0),
TotalMb = r.Double(1),
SingleUseAdhocPlans = r.Long(2),
SingleUseAdhocMb = r.Double(3),
}, context.CancellationToken).ConfigureAwait(false);
return snapshot!;
}
}

View file

@ -0,0 +1,65 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Collectors.Instance;
public sealed class ServerCollector : Collector<ServerSnapshot>
{
public override string Name => "server";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
protected override async Task<ServerSnapshot> CollectAsync(CollectorContext context, ServerSnapshot? previous)
{
var version = context.Instance.Version;
var ct = context.CancellationToken;
if (version.Major < 9)
{
return await CollectSql2000Async(context).ConfigureAwait(false);
}
var memory = version.Major >= 11 ? "physical_memory_kb / 1024" : "physical_memory_in_bytes / 1048576";
var vmType = version >= new Core.Instances.SqlServerVersion(10, 50, 2500, 0) ? "virtual_machine_type_desc" : "CAST(NULL AS nvarchar(60))";
var sql = $"""
SELECT
{Db.StartTimeExpression(version)},
cpu_count,
(SELECT COUNT(*) FROM sys.dm_os_schedulers WHERE status = N'VISIBLE ONLINE'),
(SELECT COUNT(DISTINCT parent_node_id) FROM sys.dm_os_schedulers WHERE status = N'VISIBLE ONLINE'),
{memory},
{vmType}
FROM sys.dm_os_sys_info
""";
var snapshot = await Db.QuerySingleAsync(context.Connection, sql, r => new ServerSnapshot
{
CapturedAt = context.Now,
SqlServerStartTime = r.DateTime(0),
LogicalCpuCount = r.Int(1),
VisibleOnlineSchedulers = r.Int(2),
NumaNodeCount = version.Major >= 10 ? r.Int(3) : null,
PhysicalMemoryMb = r.Long(4),
VirtualMachineType = r.NullableString(5),
}, ct).ConfigureAwait(false);
return snapshot ?? throw new InvalidOperationException("sys.dm_os_sys_info returned no rows.");
}
private static async Task<ServerSnapshot> CollectSql2000Async(CollectorContext context)
{
var ct = context.CancellationToken;
var startTime = await Db.StartTimeAsync(context.Connection, context.Instance.Version, ct).ConfigureAwait(false);
// xp_msver: Index, Name, Internal_Value, Character_Value.
var msver = await Db.QueryAsync(context.Connection, "EXEC master.dbo.xp_msver", r => (Name: r.String(1), Value: r.NullableLong(2)), ct)
.ConfigureAwait(false);
long? Get(string name) => msver.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)).Value;
return new ServerSnapshot
{
CapturedAt = context.Now,
SqlServerStartTime = startTime,
LogicalCpuCount = (int)(Get("ProcessorCount") ?? 0),
PhysicalMemoryMb = Get("PhysicalMemory"),
};
}
}

View file

@ -0,0 +1,28 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
public sealed class SuspectPageCollector : Collector<SuspectPageSnapshot>
{
internal const string Sql = """
SELECT ISNULL(DB_NAME(database_id), CAST(database_id AS nvarchar(20))), file_id, page_id, event_type, error_count, last_update_date
FROM msdb.dbo.suspect_pages
""";
public override string Name => "suspect-pages";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
// msdb.dbo.suspect_pages exists from SQL Server 2005.
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<SuspectPageSnapshot> CollectAsync(CollectorContext context, SuspectPageSnapshot? previous)
{
var pages = await Db.QueryAsync(
context.Connection,
Sql,
r => new SuspectPage(r.String(0), r.Int(1), r.Long(2), r.Int(3), r.Int(4), r.DateTime(5)),
context.CancellationToken).ConfigureAwait(false);
return new SuspectPageSnapshot(context.Now, pages);
}
}

View file

@ -0,0 +1,21 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Collectors.Instance;
public sealed class TraceFlagCollector : Collector<TraceFlagSnapshot>
{
public override string Name => "trace-flags";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
protected override async Task<TraceFlagSnapshot> CollectAsync(CollectorContext context, TraceFlagSnapshot? previous)
{
// 2005+: TraceFlag, Status, Global, Session. 2000: TraceFlag, Status (only global flags are listed with -1).
var rows = await Db.QueryAsync(
context.Connection,
"DBCC TRACESTATUS(-1) WITH NO_INFOMSGS",
r => (Flag: r.Int(0), Enabled: r.Bool(1), Global: r.FieldCount < 3 || r.Bool(2)),
context.CancellationToken).ConfigureAwait(false);
return new TraceFlagSnapshot(context.Now, rows.Where(r => r.Enabled && r.Global).Select(r => r.Flag).Distinct().Order().ToList());
}
}

View file

@ -0,0 +1,42 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Performance;
/// <summary>
/// The scheduler monitor writes one record per minute with SQL Server's own CPU and system idle.
/// Everything else is other processes on the host.
/// </summary>
public sealed class CpuCollector : Collector<CpuUtilization>
{
internal const string Sql = """
DECLARE @now_ticks bigint;
SELECT @now_ticks = ms_ticks FROM sys.dm_os_sys_info;
SELECT TOP (60)
DATEADD(second, CAST((rb.[timestamp] - @now_ticks) / 1000 AS int), GETUTCDATE()),
rb.record.value('(./Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]', 'int'),
rb.record.value('(./Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'int')
FROM (
SELECT [timestamp], CONVERT(xml, record) AS record
FROM sys.dm_os_ring_buffers
WHERE ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR' AND record LIKE N'%<SystemHealth>%'
) rb
ORDER BY rb.[timestamp] DESC
""";
public override string Name => "cpu";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<CpuUtilization> CollectAsync(CollectorContext context, CpuUtilization? previous)
{
var samples = await Db.QueryAsync(
context.Connection,
Sql,
r => new CpuSample(new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(0), DateTimeKind.Utc)), r.Int(1), r.Int(2)),
context.CancellationToken).ConfigureAwait(false);
samples.Reverse();
return new CpuUtilization { CapturedAt = context.Now, Samples = samples };
}
}

View file

@ -0,0 +1,43 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Performance;
public sealed class FileIoCollector : Collector<CumulativeSample<FileIoSnapshot, FileIoDelta>>
{
internal const string Sql = """
SELECT DB_NAME(vfs.database_id), vfs.file_id, mf.type_desc, mf.physical_name,
vfs.num_of_reads, vfs.num_of_bytes_read, vfs.io_stall_read_ms,
vfs.num_of_writes, vfs.num_of_bytes_written, vfs.io_stall_write_ms
FROM sys.dm_io_virtual_file_stats(NULL, NULL) vfs
JOIN sys.master_files mf ON mf.database_id = vfs.database_id AND mf.file_id = vfs.file_id
WHERE mf.type_desc IN (N'ROWS', N'LOG')
""";
public override string Name => "file-io";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<CumulativeSample<FileIoSnapshot, FileIoDelta>> CollectAsync(
CollectorContext context, CumulativeSample<FileIoSnapshot, FileIoDelta>? previous)
{
var ct = context.CancellationToken;
var startTime = await Db.StartTimeAsync(context.Connection, context.Instance.Version, ct).ConfigureAwait(false);
var files = await Db.QueryAsync(context.Connection, Sql, r => new FileIoStat
{
DatabaseName = r.NullableString(0) ?? "?",
FileId = r.Int(1),
FileType = r.String(2),
PhysicalName = r.String(3),
Reads = r.Long(4),
BytesRead = r.Long(5),
ReadStallMs = r.Long(6),
Writes = r.Long(7),
BytesWritten = r.Long(8),
WriteStallMs = r.Long(9),
}, ct).ConfigureAwait(false);
var snapshot = new FileIoSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Files = files };
return new(snapshot, FileIoDelta.Compute(previous?.Snapshot, snapshot));
}
}

View file

@ -0,0 +1,47 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Collectors.Performance;
public sealed class PerformanceCounterCollector : Collector<CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>>
{
// object_name is 'SQLServer:…' on a default instance and 'MSSQL$NAME:…' on a named one, so match the suffix.
internal static string BuildSql(bool dmv) => $"""
SELECT RTRIM(object_name), RTRIM(counter_name), CAST(cntr_value AS bigint)
FROM {(dmv ? "sys.dm_os_performance_counters" : "master.dbo.sysperfinfo")}
WHERE (object_name LIKE N'%Memory Manager%' AND counter_name IN (N'Memory Grants Pending', N'Memory Grants Outstanding'))
OR (object_name LIKE N'%Buffer Manager%' AND counter_name = N'Page life expectancy')
OR (object_name LIKE N'%General Statistics%' AND counter_name IN (N'User Connections', N'Processes blocked'))
OR (object_name LIKE N'%SQL Statistics%' AND counter_name IN (N'Batch Requests/sec', N'SQL Compilations/sec', N'SQL Re-Compilations/sec'))
""";
public override string Name => "performance-counters";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
protected override async Task<CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>> CollectAsync(
CollectorContext context, CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>? previous)
{
var version = context.Instance.Version;
var ct = context.CancellationToken;
var startTime = await Db.StartTimeAsync(context.Connection, version, ct).ConfigureAwait(false);
var rows = await Db.QueryAsync(context.Connection, BuildSql(version.Major >= 9), r => (Counter: r.String(1), Value: r.Long(2)), ct)
.ConfigureAwait(false);
long? Get(string name) => rows.Where(r => string.Equals(r.Counter, name, StringComparison.OrdinalIgnoreCase))
.Select(r => (long?)r.Value).FirstOrDefault();
var snapshot = new PerformanceCounterSnapshot
{
CapturedAt = context.Now,
SqlServerStartTime = startTime,
MemoryGrantsPending = Get("Memory Grants Pending"),
MemoryGrantsOutstanding = Get("Memory Grants Outstanding"),
PageLifeExpectancySeconds = Get("Page life expectancy"),
UserConnections = Get("User Connections"),
ProcessesBlocked = Get("Processes blocked"),
BatchRequests = Get("Batch Requests/sec"),
SqlCompilations = Get("SQL Compilations/sec"),
SqlRecompilations = Get("SQL Re-Compilations/sec"),
};
return new(snapshot, PerformanceCounterRates.Compute(previous?.Snapshot, snapshot));
}
}

View file

@ -0,0 +1,34 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Collectors.Performance;
public sealed class WaitStatsCollector : Collector<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>>
{
internal const string Sql2005 = """
SELECT wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms
FROM sys.dm_os_wait_stats
WHERE wait_time_ms > 0
""";
// SQL Server 2000: Wait Type, Requests, Wait Time, Signal Wait Time. Needs elevated rights; see docs/legacy-versions.md.
internal const string Sql2000 = "DBCC SQLPERF(WAITSTATS) WITH NO_INFOMSGS";
public override string Name => "wait-stats";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
protected override async Task<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>> CollectAsync(
CollectorContext context, CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>? previous)
{
var version = context.Instance.Version;
var ct = context.CancellationToken;
var startTime = await Db.StartTimeAsync(context.Connection, version, ct).ConfigureAwait(false);
var waits = await Db.QueryAsync(
context.Connection,
version.Major >= 9 ? Sql2005 : Sql2000,
r => new WaitStat(r.String(0), r.Long(1), r.Long(2), r.Long(3)),
ct).ConfigureAwait(false);
var snapshot = new WaitStatsSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Waits = waits };
return new(snapshot, WaitStatsDelta.Compute(previous?.Snapshot, snapshot));
}
}

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Collectors</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.Collectors.Tests" />
<InternalsVisibleTo Include="SQLInsight.IntegrationTests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,87 @@
namespace SQLInsight.Core.Collection;
/// <summary>
/// The latest data collected from one instance, keyed by data type. A collector that could not run records why,
/// so rules depending on it are reported as not measurable with that reason.
/// </summary>
public sealed class CollectedData
{
private readonly Dictionary<Type, object> _values = [];
private readonly Dictionary<Type, string> _unavailable = [];
public void Set<T>(T value)
where T : class
{
ArgumentNullException.ThrowIfNull(value);
_values[typeof(T)] = value;
_unavailable.Remove(typeof(T));
}
public void Set(Type type, object value)
{
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(value);
_values[type] = value;
_unavailable.Remove(type);
}
public void MarkUnavailable(Type type, string reason)
{
ArgumentNullException.ThrowIfNull(type);
_values.Remove(type);
_unavailable[type] = reason;
}
public void MarkUnavailable<T>(string reason) => MarkUnavailable(typeof(T), reason);
public bool TryGet<T>(out T value)
where T : class
{
if (_values.TryGetValue(typeof(T), out var stored))
{
value = (T)stored;
return true;
}
value = null!;
return false;
}
/// <summary>
/// Returns the data, or throws <see cref="DataUnavailableException"/> which the rule engine reports as
/// not measurable.
/// </summary>
public T Get<T>()
where T : class
{
if (TryGet<T>(out var value))
{
return value;
}
throw new DataUnavailableException(
_unavailable.TryGetValue(typeof(T), out var reason)
? reason
: $"Der er endnu ikke indsamlet data af typen {typeof(T).Name}.");
}
}
/// <summary>
/// Thrown when a rule needs data that has not been, or could not be, collected.
/// </summary>
public sealed class DataUnavailableException : Exception
{
public DataUnavailableException()
{
}
public DataUnavailableException(string message)
: base(message)
{
}
public DataUnavailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,48 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Sessions that were blocked, and the sessions blocking them, at the moment of collection.
/// </summary>
public sealed record BlockingSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<SessionInfo> Sessions { get; init; }
/// <summary>Sessions that block others without being blocked themselves.</summary>
public IEnumerable<SessionInfo> HeadBlockers
{
get
{
var blocked = Sessions.Where(s => s.BlockingSessionId is > 0).Select(s => s.SessionId).ToHashSet();
var blockers = Sessions.Where(s => s.BlockingSessionId is > 0).Select(s => s.BlockingSessionId!.Value).ToHashSet();
return Sessions.Where(s => blockers.Contains(s.SessionId) && !blocked.Contains(s.SessionId));
}
}
public IEnumerable<SessionInfo> BlockedBy(int sessionId) => Sessions.Where(s => s.BlockingSessionId == sessionId);
}
public sealed record SessionInfo
{
public required int SessionId { get; init; }
/// <summary>The session this one is waiting on, or <see langword="null"/>.</summary>
public int? BlockingSessionId { get; init; }
/// <summary>running, runnable, suspended, sleeping …</summary>
public string? Status { get; init; }
public string? WaitType { get; init; }
public long? WaitTimeMs { get; init; }
public string? WaitResource { get; init; }
public string? DatabaseName { get; init; }
public string? LoginName { get; init; }
public string? HostName { get; init; }
public string? ProgramName { get; init; }
public int OpenTransactionCount { get; init; }
/// <summary>The current or last statement, truncated. Never includes parameter values.</summary>
public string? SqlText { get; init; }
public DateTime? LastRequestEndTime { get; init; }
}

View file

@ -0,0 +1,18 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Server configuration options (<c>sys.configurations</c> / <c>sp_configure</c>).
/// </summary>
public sealed record ConfigurationSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<ConfigurationOption> Options { get; init; }
public ConfigurationOption? Find(string name) =>
Options.FirstOrDefault(o => string.Equals(o.Name, name, StringComparison.OrdinalIgnoreCase));
}
/// <param name="Name">Option name as shown by <c>sp_configure</c>, e.g. <c>max server memory (MB)</c>.</param>
/// <param name="Value">The configured value (what <c>RECONFIGURE</c> will apply).</param>
/// <param name="ValueInUse">The value currently in effect.</param>
public sealed record ConfigurationOption(string Name, long Value, long ValueInUse);

View file

@ -0,0 +1,18 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// CPU use per minute from the scheduler monitor ring buffer (<c>sys.dm_os_ring_buffers</c>, 2005+).
/// Distinguishes SQL Server's own CPU from other processes on the host.
/// </summary>
public sealed record CpuUtilization
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>One sample per minute, oldest first.</summary>
public required IReadOnlyList<CpuSample> Samples { get; init; }
}
public sealed record CpuSample(DateTimeOffset Time, int SqlServerPercent, int IdlePercent)
{
public int OtherProcessesPercent => Math.Max(0, 100 - SqlServerPercent - IdlePercent);
}

View file

@ -0,0 +1,10 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// A snapshot of cumulative counters and, from the second collection on, the change since the previous snapshot.
/// Counters reset when SQL Server restarts (or are cleared manually); the delta is then <see langword="null"/>
/// for one collection instead of producing negative or meaningless numbers.
/// </summary>
public sealed record CumulativeSample<TSnapshot, TDelta>(TSnapshot Snapshot, TDelta? Delta)
where TSnapshot : class
where TDelta : class;

View file

@ -0,0 +1,89 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Settings, backups, integrity checks and files for every database on the instance. All times are UTC.
/// </summary>
public sealed record DatabaseSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<DatabaseInfo> Databases { get; init; }
/// <summary>tempdb's files. tempdb itself is not in <see cref="Databases"/>: it is recreated at every start.</summary>
public IReadOnlyList<DatabaseFileInfo> TempdbFiles { get; init; } = [];
}
public sealed record DatabaseInfo
{
public required string Name { get; init; }
public required int DatabaseId { get; init; }
/// <summary>ONLINE, RESTORING, RECOVERING, RECOVERY_PENDING, SUSPECT, EMERGENCY, OFFLINE …</summary>
public required string State { get; init; }
/// <summary>FULL, BULK_LOGGED or SIMPLE.</summary>
public required string RecoveryModel { get; init; }
public required int CompatibilityLevel { get; init; }
/// <summary>CHECKSUM, TORN_PAGE_DETECTION or NONE. <see langword="null"/> on SQL Server 2000.</summary>
public string? PageVerify { get; init; }
public bool IsAutoClose { get; init; }
public bool IsAutoShrink { get; init; }
public bool IsReadOnly { get; init; }
public bool IsTrustworthy { get; init; }
/// <summary>True for a readable/non-readable AG secondary or a database mirroring target.</summary>
public bool IsReplicaOrMirror { get; init; }
/// <summary>
/// Member of an Always On availability group. Backups may be taken on another replica, so they are not
/// necessarily in this instance's msdb.
/// </summary>
public bool IsInAvailabilityGroup { get; init; }
public DateTime? CreateDate { get; init; }
public DateTime? LastFullBackup { get; init; }
public DateTime? LastDifferentialBackup { get; init; }
public DateTime? LastLogBackup { get; init; }
/// <summary>
/// Last successful DBCC CHECKDB. <see langword="null"/> when the version cannot report it without sysadmin;
/// 1900-01-01 when CHECKDB has never completed.
/// </summary>
public DateTime? LastGoodCheckDb { get; init; }
/// <summary>Virtual log files in the transaction log. <see langword="null"/> when not measurable on this version.</summary>
public int? VirtualLogFileCount { get; init; }
/// <summary>Query Store state. <see langword="null"/> before 2016 or when the database could not be read.</summary>
public QueryStoreState? QueryStore { get; init; }
public IReadOnlyList<DatabaseFileInfo> Files { get; init; } = [];
public bool IsSystemDatabase => DatabaseId <= 4;
}
/// <param name="DesiredState">OFF, READ_ONLY or READ_WRITE.</param>
/// <param name="ActualState">OFF, READ_ONLY, READ_WRITE or ERROR.</param>
/// <param name="ReadOnlyReason">Bit mask from <c>sys.database_query_store_options.readonly_reason</c>. 65536 means the size limit was reached.</param>
public sealed record QueryStoreState(string DesiredState, string ActualState, int ReadOnlyReason, long CurrentStorageMb, long MaxStorageMb);
public sealed record DatabaseFileInfo
{
public required string LogicalName { get; init; }
/// <summary>ROWS or LOG.</summary>
public required string Type { get; init; }
public required string PhysicalName { get; init; }
public required long SizeMb { get; init; }
/// <summary>Growth in MB, or in percent when <see cref="IsPercentGrowth"/>. 0 means autogrowth is off.</summary>
public required long Growth { get; init; }
public required bool IsPercentGrowth { get; init; }
/// <summary>Maximum size in MB, or -1 for unlimited.</summary>
public required long MaxSizeMb { get; init; }
}

View file

@ -0,0 +1,44 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Deadlocks captured by the <c>system_health</c> Extended Events session since the previous collection.
/// </summary>
public sealed record DeadlockSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>Deadlocks newer than the previous collection.</summary>
public required IReadOnlyList<DeadlockEvent> NewDeadlocks { get; init; }
/// <summary>Newest event time seen so far; the next collection starts after it.</summary>
public DateTimeOffset? LastEventTime { get; init; }
/// <summary>Where reading stopped in the event file, so the next read does not rescan all files.</summary>
public string? LastFileName { get; init; }
public long? LastFileOffset { get; init; }
}
public sealed record DeadlockEvent
{
public required DateTimeOffset Time { get; init; }
public required IReadOnlyList<DeadlockProcess> Processes { get; init; }
/// <summary>The raw deadlock graph (XDL), for download and viewing in SSMS.</summary>
public required string GraphXml { get; init; }
}
public sealed record DeadlockProcess
{
public required string Id { get; init; }
public bool IsVictim { get; init; }
public int? SessionId { get; init; }
public string? DatabaseName { get; init; }
public string? LoginName { get; init; }
public string? HostName { get; init; }
public string? ClientApp { get; init; }
public string? IsolationLevel { get; init; }
public string? WaitResource { get; init; }
public string? LockMode { get; init; }
public string? InputBuffer { get; init; }
}

View file

@ -0,0 +1,73 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Cumulative I/O per database file (<c>sys.dm_io_virtual_file_stats</c>).
/// </summary>
public sealed record FileIoSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList<FileIoStat> Files { get; init; }
}
public sealed record FileIoStat
{
public required string DatabaseName { get; init; }
public required int FileId { get; init; }
/// <summary>ROWS or LOG.</summary>
public required string FileType { get; init; }
public required string PhysicalName { get; init; }
public required long Reads { get; init; }
public required long BytesRead { get; init; }
public required long ReadStallMs { get; init; }
public required long Writes { get; init; }
public required long BytesWritten { get; init; }
public required long WriteStallMs { get; init; }
public double? AverageReadLatencyMs => Reads == 0 ? null : (double)ReadStallMs / Reads;
public double? AverageWriteLatencyMs => Writes == 0 ? null : (double)WriteStallMs / Writes;
}
/// <summary>
/// I/O per file between two snapshots.
/// </summary>
public sealed record FileIoDelta
{
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public required IReadOnlyList<FileIoStat> Files { get; init; }
public static FileIoDelta? Compute(FileIoSnapshot? previous, FileIoSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous is null || previous.SqlServerStartTime != current.SqlServerStartTime || current.CapturedAt <= previous.CapturedAt)
{
return null;
}
var before = previous.Files.ToDictionary(f => (f.DatabaseName, f.FileId));
var files = new List<FileIoStat>();
foreach (var now in current.Files)
{
// A database restored or attached since the last snapshot starts from zero again; skip it this time.
if (!before.TryGetValue((now.DatabaseName, now.FileId), out var then) || now.Reads < then.Reads || now.Writes < then.Writes)
{
continue;
}
files.Add(now with
{
Reads = now.Reads - then.Reads,
BytesRead = now.BytesRead - then.BytesRead,
ReadStallMs = now.ReadStallMs - then.ReadStallMs,
Writes = now.Writes - then.Writes,
BytesWritten = now.BytesWritten - then.BytesWritten,
WriteStallMs = now.WriteStallMs - then.WriteStallMs,
});
}
return new FileIoDelta { From = previous.CapturedAt, To = current.CapturedAt, Files = files };
}
}

View file

@ -0,0 +1,36 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Globally enabled trace flags (<c>DBCC TRACESTATUS(-1)</c>).
/// </summary>
public sealed record TraceFlagSnapshot(DateTimeOffset CapturedAt, IReadOnlyList<int> GlobalTraceFlags)
{
public bool IsEnabled(int flag) => GlobalTraceFlags.Contains(flag);
}
/// <summary>
/// Pages SQL Server has recorded as damaged (<c>msdb.dbo.suspect_pages</c>, 823/824/825 errors).
/// </summary>
public sealed record SuspectPageSnapshot(DateTimeOffset CapturedAt, IReadOnlyList<SuspectPage> Pages);
/// <param name="EventType">1: 823/824 error, 2: bad checksum, 3: torn page, 4: restored, 5: repaired, 7: deallocated.</param>
public sealed record SuspectPage(string DatabaseName, int FileId, long PageId, int EventType, int ErrorCount, DateTime LastUpdate)
{
/// <summary>Event types 4, 5 and 7 mean the page has been fixed.</summary>
public bool IsUnresolved => EventType is 1 or 2 or 3;
}
/// <summary>
/// Plan cache composition (<c>sys.dm_exec_cached_plans</c>).
/// </summary>
public sealed record PlanCacheSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required long TotalPlans { get; init; }
public required double TotalMb { get; init; }
/// <summary>Ad hoc plans used exactly once. A high share means plan cache bloat.</summary>
public required long SingleUseAdhocPlans { get; init; }
public required double SingleUseAdhocMb { get; init; }
}

View file

@ -0,0 +1,56 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Selected SQL Server performance counters (<c>sys.dm_os_performance_counters</c> / <c>sysperfinfo</c>).
/// Point-in-time counters are used as-is; per-second counters are cumulative and need a delta.
/// </summary>
public sealed record PerformanceCounterSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public long? MemoryGrantsPending { get; init; }
public long? MemoryGrantsOutstanding { get; init; }
public long? PageLifeExpectancySeconds { get; init; }
public long? UserConnections { get; init; }
public long? ProcessesBlocked { get; init; }
/// <summary>Cumulative since start.</summary>
public long? BatchRequests { get; init; }
/// <summary>Cumulative since start.</summary>
public long? SqlCompilations { get; init; }
/// <summary>Cumulative since start.</summary>
public long? SqlRecompilations { get; init; }
}
public sealed record PerformanceCounterRates
{
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public double? BatchRequestsPerSecond { get; init; }
public double? CompilationsPerSecond { get; init; }
public double? RecompilationsPerSecond { get; init; }
public static PerformanceCounterRates? Compute(PerformanceCounterSnapshot? previous, PerformanceCounterSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous is null || previous.SqlServerStartTime != current.SqlServerStartTime || current.CapturedAt <= previous.CapturedAt)
{
return null;
}
var seconds = (current.CapturedAt - previous.CapturedAt).TotalSeconds;
return new PerformanceCounterRates
{
From = previous.CapturedAt,
To = current.CapturedAt,
BatchRequestsPerSecond = Rate(previous.BatchRequests, current.BatchRequests, seconds),
CompilationsPerSecond = Rate(previous.SqlCompilations, current.SqlCompilations, seconds),
RecompilationsPerSecond = Rate(previous.SqlRecompilations, current.SqlRecompilations, seconds),
};
}
private static double? Rate(long? before, long? after, double seconds) =>
before is { } b && after is { } a && a >= b ? (a - b) / seconds : null;
}

View file

@ -0,0 +1,26 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Hardware and process facts about the instance.
/// </summary>
public sealed record ServerSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>When SQL Server last started. Cumulative counters reset here.</summary>
public required DateTime SqlServerStartTime { get; init; }
public required int LogicalCpuCount { get; init; }
/// <summary>Visible online schedulers (can be fewer than CPUs under affinity or edition limits). 2005+.</summary>
public int? VisibleOnlineSchedulers { get; init; }
/// <summary>NUMA nodes with online schedulers. 2008+.</summary>
public int? NumaNodeCount { get; init; }
/// <summary>Physical memory of the host in MB. 2005+.</summary>
public long? PhysicalMemoryMb { get; init; }
/// <summary>NONE, HYPERVISOR or OTHER. 2008 R2 SP1+.</summary>
public string? VirtualMachineType { get; init; }
}

View file

@ -0,0 +1,84 @@
using System.Collections.Frozen;
namespace SQLInsight.Core.Data;
/// <summary>
/// What wait types mean. Most of the 900+ wait types are background tasks that wait by design; including them
/// makes a healthy idle server look busy. Only the rest says something about the workload.
/// </summary>
public static class WaitCatalog
{
private static readonly FrozenSet<string> Benign = new[]
{
"BROKER_EVENTHANDLER", "BROKER_RECEIVE_WAITFOR", "BROKER_TASK_STOP", "BROKER_TO_FLUSH", "BROKER_TRANSMITTER",
"CHECKPOINT_QUEUE", "CHKPT", "CLR_AUTO_EVENT", "CLR_MANUAL_EVENT", "CLR_SEMAPHORE",
// CXCONSUMER (2016 SP2+/2017 CU3+) is the harmless consumer side of parallelism; CXPACKET is what matters.
"CXCONSUMER",
"DBMIRROR_DBM_EVENT", "DBMIRROR_EVENTS_QUEUE", "DBMIRROR_WORKER_QUEUE", "DBMIRRORING_CMD",
"DIRTY_PAGE_POLL", "DISPATCHER_QUEUE_SEMAPHORE", "EXECSYNC", "FSAGENT",
"FT_IFTS_SCHEDULER_IDLE_WAIT", "FT_IFTSHC_MUTEX",
"HADR_CLUSAPI_CALL", "HADR_FILESTREAM_IOMGR_IOCOMPLETION", "HADR_LOGCAPTURE_WAIT", "HADR_NOTIFICATION_DEQUEUE",
"HADR_TIMER_TASK", "HADR_WORK_QUEUE",
"KSOURCE_WAKEUP", "LAZYWRITER_SLEEP", "LOGMGR_QUEUE", "MEMORY_ALLOCATION_EXT", "ONDEMAND_TASK_QUEUE",
"PREEMPTIVE_HADR_LEASE_MECHANISM", "PREEMPTIVE_OS_FLUSHFILEBUFFERS", "PREEMPTIVE_SP_SERVER_DIAGNOSTICS",
"PREEMPTIVE_XE_GETTARGETSTATE", "PVS_PREALLOCATE",
"REDO_THREAD_PENDING_WORK", "REQUEST_FOR_DEADLOCK_SEARCH", "RESOURCE_QUEUE", "SERVER_IDLE_CHECK",
"SNI_HTTP_ACCEPT", "SOS_WORK_DISPATCHER", "SP_SERVER_DIAGNOSTICS_SLEEP",
"SQLTRACE_BUFFER_FLUSH", "SQLTRACE_INCREMENTAL_FLUSH_SLEEP", "SQLTRACE_WAIT_ENTRIES",
"UCS_SESSION_REGISTRATION", "VDI_CLIENT_OTHER", "WAIT_FOR_RESULTS", "WAITFOR", "WAITFOR_TASKSHUTDOWN",
"WAIT_XTP_CKPT_CLOSE", "WAIT_XTP_HOST_WAIT", "WAIT_XTP_OFFLINE_CKPT_NEW_LOG", "WAIT_XTP_RECOVERY",
"XE_BUFFERMGR_ALLPROCESSED_EVENT", "XE_DISPATCHER_JOIN", "XE_DISPATCHER_WAIT", "XE_LIVE_TARGET_TVF",
"XE_TIMER_EVENT",
// SQL Server 2000 DBCC SQLPERF(WAITSTATS) summary row.
"Total",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private static readonly string[] BenignPrefixes = ["SLEEP_", "QDS_", "PWAIT_", "PARALLEL_REDO_"];
public static bool IsBenign(string waitType)
{
ArgumentNullException.ThrowIfNull(waitType);
return Benign.Contains(waitType) || BenignPrefixes.Any(p => waitType.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
public static WaitCategory Categorize(string waitType)
{
ArgumentNullException.ThrowIfNull(waitType);
var w = waitType.ToUpperInvariant();
return w switch
{
"THREADPOOL" => WaitCategory.WorkerThreads,
"SOS_SCHEDULER_YIELD" => WaitCategory.Cpu,
"CXPACKET" or "CXSYNC_PORT" or "CXSYNC_CONSUMER" or "EXCHANGE" => WaitCategory.Parallelism,
"WRITELOG" or "LOGBUFFER" => WaitCategory.TransactionLog,
"RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" or "CMEMTHREAD" => WaitCategory.Memory,
"ASYNC_NETWORK_IO" or "NETWORKIO" => WaitCategory.Network,
"IO_COMPLETION" or "ASYNC_IO_COMPLETION" or "BACKUPIO" => WaitCategory.OtherIo,
_ when w.StartsWith("LCK_M_", StringComparison.Ordinal) => WaitCategory.Locks,
_ when w.StartsWith("PAGEIOLATCH_", StringComparison.Ordinal) => WaitCategory.BufferIo,
_ when w.StartsWith("PAGELATCH_", StringComparison.Ordinal) => WaitCategory.PageLatch,
_ when w.StartsWith("LATCH_", StringComparison.Ordinal) => WaitCategory.Latch,
_ when w.StartsWith("HADR_SYNC_COMMIT", StringComparison.Ordinal) => WaitCategory.AvailabilityGroups,
_ when w.StartsWith("PREEMPTIVE_", StringComparison.Ordinal) => WaitCategory.Preemptive,
_ => WaitCategory.Other,
};
}
}
public enum WaitCategory
{
Other,
Cpu,
WorkerThreads,
Parallelism,
Locks,
BufferIo,
PageLatch,
Latch,
TransactionLog,
Memory,
Network,
OtherIo,
AvailabilityGroups,
Preemptive,
}

View file

@ -0,0 +1,72 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Cumulative wait statistics since the instance started (<c>sys.dm_os_wait_stats</c> / <c>DBCC SQLPERF(WAITSTATS)</c>).
/// </summary>
public sealed record WaitStatsSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList<WaitStat> Waits { get; init; }
}
public sealed record WaitStat(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs)
{
/// <summary>Time spent waiting for the resource itself, excluding time waiting for a CPU afterwards.</summary>
public long ResourceWaitTimeMs => WaitTimeMs - SignalWaitTimeMs;
}
/// <summary>
/// Waits accumulated between two snapshots, with benign waits removed.
/// </summary>
public sealed record WaitStatsDelta
{
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public required IReadOnlyList<WaitStat> Waits { get; init; }
public TimeSpan Interval => To - From;
public long TotalWaitTimeMs => Waits.Sum(w => w.WaitTimeMs);
public long TotalSignalWaitTimeMs => Waits.Sum(w => w.SignalWaitTimeMs);
public static WaitStatsDelta? Compute(WaitStatsSnapshot? previous, WaitStatsSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous is null || previous.SqlServerStartTime != current.SqlServerStartTime || current.CapturedAt <= previous.CapturedAt)
{
return null;
}
var before = previous.Waits.ToDictionary(w => w.WaitType, StringComparer.OrdinalIgnoreCase);
var waits = new List<WaitStat>();
foreach (var now in current.Waits)
{
if (WaitCatalog.IsBenign(now.WaitType))
{
continue;
}
before.TryGetValue(now.WaitType, out var then);
var tasks = now.WaitingTasks - (then?.WaitingTasks ?? 0);
var time = now.WaitTimeMs - (then?.WaitTimeMs ?? 0);
var signal = now.SignalWaitTimeMs - (then?.SignalWaitTimeMs ?? 0);
if (tasks < 0 || time < 0 || signal < 0)
{
// DBCC SQLPERF('sys.dm_os_wait_stats', CLEAR) was run. The interval is meaningless.
return null;
}
if (time > 0)
{
waits.Add(new WaitStat(now.WaitType, tasks, time, signal));
}
}
return new WaitStatsDelta
{
From = previous.CapturedAt,
To = current.CapturedAt,
Waits = waits.OrderByDescending(w => w.WaitTimeMs).ToList(),
};
}
}

View file

@ -9,6 +9,13 @@ public sealed record Finding
public required Severity Severity { get; init; } public required Severity Severity { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
/// <summary>
/// What the finding is about when a rule can report several, e.g. <c>database: Sales</c> or <c>file: Sales_log</c>.
/// Together with <see cref="RuleId"/> it identifies the finding across collections, so it can be tracked
/// (first seen, last seen, resolved). <see langword="null"/> for instance-wide findings.
/// </summary>
public string? Subject { get; init; }
/// <summary>Why this is a problem.</summary> /// <summary>Why this is a problem.</summary>
public required string Explanation { get; init; } public required string Explanation { get; init; }

View file

@ -41,6 +41,9 @@ public enum Capability
/// <summary><c>DATABASEPROPERTYEX(..., 'LastGoodCheckDbTime')</c>, so CHECKDB status does not need sysadmin. SQL Server 2016 SP2+.</summary> /// <summary><c>DATABASEPROPERTYEX(..., 'LastGoodCheckDbTime')</c>, so CHECKDB status does not need sysadmin. SQL Server 2016 SP2+.</summary>
LastGoodCheckDbTime, LastGoodCheckDbTime,
/// <summary><c>sys.dm_db_log_info</c>, so VLFs can be counted without sysadmin. SQL Server 2016 SP2+.</summary>
LogInfoDmv,
/// <summary><c>sys.dm_exec_query_plan_stats</c> (last actual execution plan). SQL Server 2019+.</summary> /// <summary><c>sys.dm_exec_query_plan_stats</c> (last actual execution plan). SQL Server 2019+.</summary>
LastActualPlan, LastActualPlan,

View file

@ -20,6 +20,7 @@ public sealed class InstanceCapabilities
[Capability.QueryStore] = new(13, 0, 0, 0), [Capability.QueryStore] = new(13, 0, 0, 0),
[Capability.SessionWaitStats] = new(13, 0, 0, 0), [Capability.SessionWaitStats] = new(13, 0, 0, 0),
[Capability.LastGoodCheckDbTime] = new(13, 0, 5026, 0), // 2016 SP2 [Capability.LastGoodCheckDbTime] = new(13, 0, 5026, 0), // 2016 SP2
[Capability.LogInfoDmv] = new(13, 0, 5026, 0), // 2016 SP2
[Capability.LastActualPlan] = new(15, 0, 0, 0), [Capability.LastActualPlan] = new(15, 0, 0, 0),
[Capability.GranularServerPermissions] = new(16, 0, 0, 0), [Capability.GranularServerPermissions] = new(16, 0, 0, 0),
}; };

View file

@ -1,5 +1,9 @@
using SQLInsight.Rules.Concurrency;
using SQLInsight.Rules.Configuration;
using SQLInsight.Rules.Databases;
using SQLInsight.Rules.Engine; using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Lifecycle; using SQLInsight.Rules.Lifecycle;
using SQLInsight.Rules.Performance;
namespace SQLInsight.Rules; namespace SQLInsight.Rules;
@ -7,6 +11,42 @@ public static class BuiltInRules
{ {
public static IReadOnlyList<IRule> Create() => public static IReadOnlyList<IRule> Create() =>
[ [
// Version
new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()), new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()),
// Configuration
new MaxServerMemoryDefaultRule(),
new MaxServerMemoryTooHighRule(),
new CostThresholdRule(),
new MaxDopRule(),
new OptimizeForAdHocRule(),
.. SimpleConfigurationRules.Create(),
new PendingConfigurationRule(),
new DeadlockTraceFlagRule(),
// Databases
new DatabaseStateRule(),
.. SimpleDatabaseRules.Create(),
new CompatibilityLevelRule(),
new FullBackupRule(),
new LogBackupRule(),
new CheckDbRule(),
new SuspectPagesRule(),
new VirtualLogFileRule(),
new FileGrowthRule(),
new FileMaxSizeRule(),
new QueryStoreStateRule(),
new TempdbFilesRule(),
// Performance
.. WaitRules.Create(),
new DataFileReadLatencyRule(),
new LogFileWriteLatencyRule(),
new CpuRule(),
new MemoryGrantsPendingRule(),
// Concurrency
new BlockingRule(),
new DeadlockRule(),
]; ];
} }

View file

@ -0,0 +1,130 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Concurrency;
/// <summary>
/// Blocking chains that have lasted long enough to hurt users.
/// </summary>
public sealed class BlockingRule : Rule
{
public const long MinBlockedMs = 30_000;
public override string Id => "SQLI-BLK-001";
public override string Title => "Langvarig blocking";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<BlockingSnapshot>();
var findings = new List<Finding>();
foreach (var head in snapshot.HeadBlockers)
{
var victims = AllBlockedBy(snapshot, head.SessionId);
var longest = victims.Max(v => v.WaitTimeMs ?? 0);
if (longest < MinBlockedMs)
{
continue;
}
var idleInTransaction = head.Status == "sleeping" && head.OpenTransactionCount > 0;
var evidence = new List<Evidence>
{
new("Head blocker", $"session {head.SessionId} ({head.Status}), {head.LoginName} fra {head.HostName} / {head.ProgramName}"),
new("Åbne transaktioner", head.OpenTransactionCount.ToString(CultureInfo.InvariantCulture)),
new("Blokerede sessioner", victims.Count.ToString(CultureInfo.InvariantCulture)),
new("Længste ventetid", $"{Format.Number(longest / 1000.0)} s"),
new("Head blockers SQL", head.SqlText ?? "(ukendt)"),
};
evidence.AddRange(victims.OrderByDescending(v => v.WaitTimeMs).Take(3).Select(v =>
new Evidence($"Blokeret session {v.SessionId}", $"{v.WaitType} på {v.WaitResource}: {v.SqlText}")));
findings.Add(CreateFinding(
longest >= 300_000 || victims.Count >= 10 ? Severity.High : Severity.Medium,
idleInTransaction
? $"Session {head.SessionId} har en åben transaktion uden at lave noget og blokerer {victims.Count} session(er)"
: $"Session {head.SessionId} blokerer {victims.Count} session(er) i op til {Format.Number(longest / 1000.0)} s",
idleInTransaction
? "Head blocker er 'sleeping' med en åben transaktion: applikationen har startet en transaktion, udført en ændring "
+ "og venter nu på noget andet (brugerinput, en webservice, en fejl der ikke blev håndteret) uden at committe. "
+ "Låsene holdes, til transaktionen afsluttes."
: "En session holder låse, som andre venter på. Så længe den kører, står alle bag den i kø.",
idleInTransaction
? "Find koden ud fra program, host og seneste SQL. Sørg for, at transaktioner altid afsluttes (også ved fejl: "
+ "try/finally, SET XACT_ABORT ON), og at der aldrig ventes på noget eksternt inde i en transaktion. "
+ "Akut: KILL af sessionen ruller transaktionen tilbage (det kan tage tid)."
: "Optimér head blockers forespørgsel (typisk manglende index), del store opdateringer i mindre batches, "
+ "og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres.",
evidence,
subject: $"session: {head.SessionId}",
references: Docs.Learn("Understand and resolve blocking", "relational-databases/sql-server-transaction-locking-and-row-versioning-guide")));
}
return Result(findings);
}
private static List<SessionInfo> AllBlockedBy(BlockingSnapshot snapshot, int headSessionId)
{
var result = new List<SessionInfo>();
var queue = new Queue<int>([headSessionId]);
var seen = new HashSet<int> { headSessionId };
while (queue.Count > 0)
{
foreach (var blocked in snapshot.BlockedBy(queue.Dequeue()))
{
if (seen.Add(blocked.SessionId))
{
result.Add(blocked);
queue.Enqueue(blocked.SessionId);
}
}
}
return result;
}
}
/// <summary>
/// Deadlocks since the previous collection.
/// </summary>
public sealed class DeadlockRule : Rule
{
public override string Id => "SQLI-DLK-001";
public override string Title => "Deadlocks";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SystemHealthSession];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var deadlocks = context.Data.Get<DeadlockSnapshot>().NewDeadlocks;
if (deadlocks.Count == 0)
{
return RuleEvaluation.Passed();
}
var evidence = new List<Evidence> { new("Antal", deadlocks.Count.ToString(CultureInfo.InvariantCulture)) };
foreach (var deadlock in deadlocks.TakeLast(3))
{
foreach (var p in deadlock.Processes)
{
evidence.Add(new Evidence(
$"{deadlock.Time:yyyy-MM-dd HH:mm:ss} {(p.IsVictim ? "offer" : "vinder")} session {p.SessionId}",
$"{p.LockMode} på {p.WaitResource}, {p.ClientApp}: {p.InputBuffer}"));
}
}
return RuleEvaluation.Found(CreateFinding(
deadlocks.Count >= 10 ? Severity.High : Severity.Medium,
$"{deadlocks.Count} deadlock(s) siden sidste måling",
"To eller flere sessioner ventede på låse, som de andre holdt. SQL Server afbrød én af dem (offeret) med fejl 1205, "
+ "og dens transaktion blev rullet tilbage. Håndterer applikationen ikke fejlen, går arbejdet tabt.",
"Se deadlock-grafen for, hvilke objekter og forespørgsler der er involveret. Typiske løsninger: tilgå tabeller i samme "
+ "rækkefølge i alle transaktioner, gør transaktioner kortere, tilføj indexes så færre rækker låses, og lad "
+ "applikationen genforsøge ved fejl 1205. READ_COMMITTED_SNAPSHOT fjerner deadlocks mellem læsere og skrivere.",
evidence,
references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide")));
}
}

View file

@ -0,0 +1,393 @@
using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Configuration;
/// <summary>
/// A server option with a value that is a known problem. Declarative, so each such piece of expertise is a few lines.
/// </summary>
public sealed class ConfigurationOptionRule(
string id,
string option,
Func<long, bool> isProblem,
Severity severity,
string title,
string explanation,
string recommendation,
long? recommendedValue,
Reference reference,
Func<RuleContext, bool>? appliesTo = null) : Rule
{
public override string Id => id;
public override string Title => title;
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (appliesTo is not null && !appliesTo(context))
{
return RuleEvaluation.Passed();
}
var value = ConfigurationValues.Get(context, option);
if (!isProblem(value.ValueInUse))
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
severity,
title,
explanation,
recommendation,
[new Evidence(option, value.ValueInUse.ToString(CultureInfo.InvariantCulture))],
fixScript: recommendedValue is { } v ? ConfigurationValues.FixScript(option, v) : null,
references: reference));
}
}
internal static class ConfigurationValues
{
public static ConfigurationOption Get(RuleContext context, string option) =>
context.Data.Get<ConfigurationSnapshot>().Find(option)
?? throw new DataUnavailableException($"Indstillingen '{option}' findes ikke på {context.Instance.Release.DisplayName()}.");
public static string FixScript(string option, long value) => $"""
EXEC sys.sp_configure N'show advanced options', 1;
RECONFIGURE;
EXEC sys.sp_configure N'{option}', {value.ToString(CultureInfo.InvariantCulture)};
RECONFIGURE;
""";
}
/// <summary>
/// The configuration rules that need no more than one option value.
/// </summary>
public static class SimpleConfigurationRules
{
public static IEnumerable<IRule> Create() =>
[
new ConfigurationOptionRule(
"SQLI-CFG-006", "priority boost", v => v != 0, Severity.High,
"Priority boost er slået til",
"Priority boost hæver SQL Servers trådprioritet over resten af Windows. Det kan udsulte operativsystemet, "
+ "netværk og cluster-tjenester, så serveren bliver ustabil eller en failover-cluster tror, noden er død. "
+ "Microsoft har markeret indstillingen som forældet.",
"Slå priority boost fra. Ændringen kræver genstart af SQL Server-tjenesten.",
0, Docs.Learn("priority boost", "database-engine/configure-windows/configure-the-priority-boost-server-configuration-option")),
new ConfigurationOptionRule(
"SQLI-CFG-007", "lightweight pooling", v => v != 0, Severity.High,
"Lightweight pooling (fiber mode) er slået til",
"Fiber mode får SQL Server til at planlægge fibre i stedet for tråde. Det understøttes ikke af blandt andet CLR, "
+ "distribuerede transaktioner og mange komponenter, og det giver sjældent en målbar gevinst på moderne hardware.",
"Slå lightweight pooling fra, medmindre Microsoft Support har anbefalet det til netop denne workload.",
0, Docs.Learn("lightweight pooling", "database-engine/configure-windows/lightweight-pooling-server-configuration-option")),
new ConfigurationOptionRule(
"SQLI-CFG-008", "xp_cmdshell", v => v != 0, Severity.Medium,
"xp_cmdshell er slået til",
"xp_cmdshell lader T-SQL køre vilkårlige kommandoer i Windows med SQL Server-tjenestekontoens rettigheder. "
+ "Et SQL injection-hul eller en kompromitteret sysadmin-konto bliver dermed til adgang til hele serveren.",
"Slå xp_cmdshell fra. Opgaver der kræver kommandolinjen, bør køre som SQL Agent-jobtrin med en proxy-konto.",
0, Docs.Learn("xp_cmdshell", "database-engine/configure-windows/xp-cmdshell-server-configuration-option")),
new ConfigurationOptionRule(
"SQLI-CFG-009", "backup compression default", v => v == 0, Severity.Low,
"Backup-komprimering er ikke standard",
"Komprimerede backups er typisk 3-5 gange mindre og hurtigere, fordi flaskehalsen oftest er I/O og ikke CPU. "
+ "Uden standardindstillingen skal hvert backup-job selv huske WITH COMPRESSION.",
"Slå backup compression default til. Undlad kun, hvis databaserne allerede er krypteret med TDE før 2016 "
+ "eller data i forvejen er komprimeret.",
1, Docs.Learn("backup compression default", "database-engine/configure-windows/view-or-configure-the-backup-compression-default-server-configuration-option"),
c => c.Instance.EngineEdition != EngineEdition.Express),
new ConfigurationOptionRule(
"SQLI-CFG-010", "remote admin connections", v => v == 0, Severity.Low,
"Dedicated Admin Connection kan kun bruges lokalt",
"Når SQL Server hænger (fx ved worker thread exhaustion), er Dedicated Admin Connection (DAC) ofte eneste vej ind. "
+ "Som standard virker den kun fra selve serveren, som man måske ikke kan logge på i den situation.",
"Slå remote admin connections til, så DAC kan bruges med ADMIN: fra en anden maskine.",
1, Docs.Learn("remote admin connections", "database-engine/configure-windows/remote-admin-connections-server-configuration-option")),
];
}
/// <summary>
/// max server memory left at the default: SQL Server may take all memory and starve Windows.
/// </summary>
public sealed class MaxServerMemoryDefaultRule : Rule
{
public const string Option = "max server memory (MB)";
public override string Id => "SQLI-CFG-001";
public override string Title => "max server memory er ikke sat";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
if (value < int.MaxValue)
{
return RuleEvaluation.Passed();
}
var physical = context.Data.TryGet<ServerSnapshot>(out var server) ? server.PhysicalMemoryMb : null;
var recommended = physical is { } p ? MemoryGuidance.Recommended(p) : (long?)null;
var evidence = new List<Evidence> { new(Option, value.ToString(CultureInfo.InvariantCulture)) };
if (physical is { } mb)
{
evidence.Add(new Evidence("Fysisk hukommelse (MB)", Format.Number(mb)));
}
return RuleEvaluation.Found(CreateFinding(
Severity.High,
Title,
"Med standardværdien må SQL Server bruge al hukommelse på serveren. Buffer pool vokser, til Windows begynder at "
+ "presse den tilbage; resultatet er paging, lange pauser og i værste fald at SQL Servers hukommelse bliver trimmet.",
recommended is { } r
? $"Sæt max server memory til ca. {Format.Number(r)} MB (fysisk hukommelse minus det største af 4 GB og 10 %), "
+ "og træk mere fra, hvis andre tjenester (SSIS, SSAS, antivirus) kører på serveren."
: "Sæt max server memory til fysisk hukommelse minus det største af 4 GB og 10 %, og træk mere fra for andre tjenester.",
evidence,
fixScript: recommended is { } fix ? ConfigurationValues.FixScript(Option, fix) : null,
references: Docs.Learn("Server memory configuration", "database-engine/configure-windows/server-memory-server-configuration-options")));
}
}
/// <summary>
/// max server memory set so high that the operating system has too little left.
/// </summary>
public sealed class MaxServerMemoryTooHighRule : Rule
{
public override string Id => "SQLI-CFG-002";
public override string Title => "max server memory efterlader for lidt til Windows";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, MaxServerMemoryDefaultRule.Option).ValueInUse;
var physical = context.Data.Get<ServerSnapshot>().PhysicalMemoryMb
?? throw new DataUnavailableException("Fysisk hukommelse kan ikke aflæses på denne version.");
// The default is reported by SQLI-CFG-001.
if (value >= int.MaxValue || physical - value >= MemoryGuidance.Reserve(physical) / 2)
{
return RuleEvaluation.Passed();
}
var recommended = MemoryGuidance.Recommended(physical);
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
Title,
$"max server memory er sat til {Format.Number(value)} MB på en server med {Format.Number(physical)} MB. "
+ "Der er ikke plads nok til Windows, tråd-stacks, CLR og andre allokeringer uden for buffer pool, så serveren risikerer paging.",
$"Sænk max server memory til ca. {Format.Number(recommended)} MB.",
[new Evidence(MaxServerMemoryDefaultRule.Option, Format.Number(value)), new Evidence("Fysisk hukommelse (MB)", Format.Number(physical))],
fixScript: ConfigurationValues.FixScript(MaxServerMemoryDefaultRule.Option, recommended),
references: Docs.Learn("Server memory configuration", "database-engine/configure-windows/server-memory-server-configuration-options")));
}
}
internal static class MemoryGuidance
{
/// <summary>Memory to leave for the OS: the larger of 4 GB and 10 %.</summary>
public static long Reserve(long physicalMb) => Math.Max(4096, physicalMb / 10);
public static long Recommended(long physicalMb) => Math.Max(physicalMb / 2, physicalMb - Reserve(physicalMb));
}
/// <summary>
/// Cost threshold for parallelism at the 1990s default of 5.
/// </summary>
public sealed class CostThresholdRule : Rule
{
public const string Option = "cost threshold for parallelism";
public override string Id => "SQLI-CFG-003";
public override string Title => "cost threshold for parallelism står på standardværdien 5";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
var maxdop = ConfigurationValues.Get(context, "max degree of parallelism").ValueInUse;
var cpus = context.Data.Get<ServerSnapshot>().LogicalCpuCount;
if (value != 5 || maxdop == 1 || cpus <= 1)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
Title,
"Standardværdien 5 blev fastlagt i 1990'erne. I dag betyder den, at selv små forespørgsler kører parallelt, "
+ "hvilket koster CPU og worker threads og ofte giver CXPACKET-ventetider uden at gøre noget hurtigere.",
"Sæt cost threshold for parallelism til 50 som udgangspunkt, og justér ud fra estimerede omkostninger i plan cache "
+ "eller Query Store. Planer, der hidtil kørte parallelt, kan ændre sig, så følg med i Query Store bagefter.",
[new Evidence(Option, "5"), new Evidence("max degree of parallelism", maxdop.ToString(CultureInfo.InvariantCulture)),
new Evidence("Logiske CPU'er", cpus.ToString(CultureInfo.InvariantCulture))],
fixScript: ConfigurationValues.FixScript(Option, 50),
references: Docs.Learn("cost threshold for parallelism", "database-engine/configure-windows/configure-the-cost-threshold-for-parallelism-server-configuration-option")));
}
}
/// <summary>
/// MAXDOP not following Microsoft's guidance for the NUMA layout.
/// </summary>
public sealed class MaxDopRule : Rule
{
public const string Option = "max degree of parallelism";
public override string Id => "SQLI-CFG-004";
public override string Title => "MAXDOP passer ikke til serverens NUMA-layout";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
var server = context.Data.Get<ServerSnapshot>();
var schedulers = server.VisibleOnlineSchedulers ?? server.LogicalCpuCount;
var nodes = Math.Max(1, server.NumaNodeCount ?? 1);
var perNode = Math.Max(1, schedulers / nodes);
var recommended = Recommended(nodes, perNode);
var tooWide = value == 0 ? recommended < schedulers : value > recommended;
if (!tooWide || schedulers <= 1)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
Title,
value == 0
? $"MAXDOP 0 lader én forespørgsel bruge alle {schedulers} schedulers. Store parallelle planer på tværs af NUMA-noder "
+ "giver fjern-hukommelsesadgang, koordineringsoverhead og kan tømme serveren for worker threads."
: $"MAXDOP {value} er højere end Microsofts vejledning for {nodes} NUMA-node(r) med {perNode} schedulers hver.",
$"Sæt MAXDOP til {recommended} efter Microsofts vejledning. På SQL Server 2016 og nyere kan det også sættes pr. database "
+ "med ALTER DATABASE SCOPED CONFIGURATION.",
[new Evidence(Option, value.ToString(CultureInfo.InvariantCulture)),
new Evidence("Schedulers", schedulers.ToString(CultureInfo.InvariantCulture)),
new Evidence("NUMA-noder", nodes.ToString(CultureInfo.InvariantCulture))],
fixScript: ConfigurationValues.FixScript(Option, recommended),
references: Docs.Learn("max degree of parallelism", "database-engine/configure-windows/configure-the-max-degree-of-parallelism-server-configuration-option")));
}
/// <summary>
/// Microsoft's guidance (SQL Server 2016+): one NUMA node → up to 8; several nodes → the schedulers per node
/// up to 16, and with more than 16 per node half of them, at most 16.
/// </summary>
internal static int Recommended(int nodes, int perNode) => nodes <= 1
? Math.Min(8, perNode)
: perNode <= 16 ? perNode : Math.Min(16, perNode / 2);
}
/// <summary>
/// Plan cache full of single-use ad hoc plans without optimize for ad hoc workloads.
/// </summary>
public sealed class OptimizeForAdHocRule : Rule
{
public const string Option = "optimize for ad hoc workloads";
public override string Id => "SQLI-CFG-005";
public override string Title => "Plan cache er fyldt med engangsplaner";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
var cache = context.Data.Get<PlanCacheSnapshot>();
var share = cache.TotalMb > 0 ? cache.SingleUseAdhocMb / cache.TotalMb : 0;
if (value != 0 || share < 0.25 || cache.SingleUseAdhocMb < 100)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
Title,
$"{Format.Number(cache.SingleUseAdhocMb)} MB ({Format.Number(share * 100)} %) af plan cache er ad hoc-planer, der kun er brugt én gang. "
+ "Den hukommelse mangler i buffer pool, og applikationen sender sandsynligvis uparametriseret SQL.",
"Slå optimize for ad hoc workloads til, så en plan først gemmes helt ved anden brug. Den egentlige løsning er at "
+ "parametrisere forespørgslerne i applikationen.",
[new Evidence("Engangsplaner", Format.Number(cache.SingleUseAdhocPlans)),
new Evidence("Engangsplaner (MB)", Format.Number(cache.SingleUseAdhocMb)),
new Evidence("Plan cache i alt (MB)", Format.Number(cache.TotalMb))],
fixScript: ConfigurationValues.FixScript(Option, 1),
references: Docs.Learn("optimize for ad hoc workloads", "database-engine/configure-windows/optimize-for-ad-hoc-workloads-server-configuration-option")));
}
}
/// <summary>
/// Options changed with sp_configure but not applied.
/// </summary>
public sealed class PendingConfigurationRule : Rule
{
// Configured 0 while SQL Server reports the effective minimum; not a pending change.
private static readonly HashSet<string> Ignored = new(StringComparer.OrdinalIgnoreCase) { "min server memory (MB)" };
public override string Id => "SQLI-CFG-011";
public override string Title => "Konfigurationsændringer venter på RECONFIGURE eller genstart";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var pending = context.Data.Get<ConfigurationSnapshot>().Options
.Where(o => o.Value != o.ValueInUse && !Ignored.Contains(o.Name))
.ToList();
if (pending.Count == 0)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
Title,
"Disse indstillinger er ændret med sp_configure, men den kørende værdi er en anden. Ændringen træder i kraft ved "
+ "næste RECONFIGURE eller genstart, måske uventet, fx når nogen ændrer en helt anden indstilling.",
"Afgør for hver indstilling, om ændringen er ønsket. Kør RECONFIGURE (eller genstart for statiske indstillinger), "
+ "eller sæt værdien tilbage.",
pending.Select(o => new Evidence(o.Name, $"konfigureret {o.Value}, i brug {o.ValueInUse}")),
references: Docs.Learn("Server configuration options", "database-engine/configure-windows/server-configuration-options-sql-server")));
}
}
/// <summary>
/// No way to see deadlocks on versions without system_health.
/// </summary>
public sealed class DeadlockTraceFlagRule : Rule
{
public override string Id => "SQLI-CFG-012";
public override string Title => "Deadlocks bliver ikke registreret";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Capabilities.Has(Capability.SystemHealthSession))
{
return RuleEvaluation.Passed();
}
var flags = context.Data.Get<TraceFlagSnapshot>();
if (flags.IsEnabled(1222) || flags.IsEnabled(1204))
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
Title,
$"{context.Instance.Release.DisplayName()} har ikke system_health-sessionen, og hverken trace flag 1222 eller 1204 er slået til. "
+ "Deadlocks kan derfor ikke analyseres bagefter; applikationen ser kun fejl 1205.",
"Slå trace flag 1222 til globalt (DBCC TRACEON(1222, -1)), og tilføj -T1222 som startparameter, så det overlever genstart. "
+ "Deadlock-grafen skrives så til error log.",
[new Evidence("Globale trace flags", flags.GlobalTraceFlags.Count == 0 ? "ingen" : string.Join(", ", flags.GlobalTraceFlags))],
fixScript: "DBCC TRACEON(1222, -1);",
references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide")));
}
}

View file

@ -0,0 +1,505 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Databases;
internal static class Databases
{
public static IEnumerable<DatabaseInfo> Online(RuleContext context) =>
context.Data.Get<DatabaseSnapshot>().Databases.Where(d => d.State == "ONLINE");
public static string Subject(DatabaseInfo database) => $"database: {database.Name}";
public static TimeSpan Age(RuleContext context, DateTime? time) => context.Now.UtcDateTime - (time ?? DateTime.MinValue);
}
/// <summary>
/// A database setting with a value that is a known problem.
/// </summary>
public sealed class DatabaseSettingRule(
string id,
string title,
Func<DatabaseInfo, bool> isProblem,
Severity severity,
Func<DatabaseInfo, string> findingTitle,
string explanation,
string recommendation,
Func<DatabaseInfo, string> fixScript,
Reference reference) : Rule
{
public override string Id => id;
public override string Title => title;
public override RuleEvaluation Evaluate(RuleContext context) =>
Result(Databases.Online(context).Where(isProblem).Select(d => CreateFinding(
severity,
findingTitle(d),
explanation,
recommendation,
subject: Databases.Subject(d),
fixScript: fixScript(d),
references: reference)));
}
public static class SimpleDatabaseRules
{
private static string Name(DatabaseInfo d) => "[" + d.Name.Replace("]", "]]", StringComparison.Ordinal) + "]";
public static IEnumerable<IRule> Create() =>
[
new DatabaseSettingRule(
"SQLI-DB-002", "AUTO_SHRINK er slået til",
d => d.IsAutoShrink, Severity.High,
d => $"AUTO_SHRINK er slået til på {d.Name}",
"Auto shrink flytter sider rundt for at frigive plads, hvilket fragmenterer indexes fuldstændigt og koster "
+ "CPU og I/O. Filerne vokser bagefter igen med autogrowth, så cyklussen gentager sig.",
"Slå AUTO_SHRINK fra. Hvis der reelt skal frigives plads, gøres det én gang manuelt og efterfulgt af index-vedligehold.",
d => $"ALTER DATABASE {Name(d)} SET AUTO_SHRINK OFF;",
Docs.Learn("AUTO_SHRINK", "relational-databases/policy-based-management/set-the-auto-shrink-database-option-to-off")),
new DatabaseSettingRule(
"SQLI-DB-003", "AUTO_CLOSE er slået til",
d => d.IsAutoClose, Severity.Medium,
d => $"AUTO_CLOSE er slået til på {d.Name}",
"Databasen lukkes, når den sidste forbindelse slipper den. Næste forbindelse skal åbne filerne og genopbygge "
+ "cachen, og plan cache for databasen tømmes hver gang. Det giver ujævne svartider og mange logbeskeder.",
"Slå AUTO_CLOSE fra.",
d => $"ALTER DATABASE {Name(d)} SET AUTO_CLOSE OFF;",
Docs.Learn("AUTO_CLOSE", "relational-databases/policy-based-management/set-the-auto-close-database-option-to-off")),
new DatabaseSettingRule(
"SQLI-DB-004", "Sidekontrol er ikke CHECKSUM",
d => d.PageVerify is not null && d.PageVerify != "CHECKSUM", Severity.Medium,
d => $"{d.Name} bruger PAGE_VERIFY {d.PageVerify}",
"Med CHECKSUM beregner SQL Server en kontrolsum for hver side, der skrives, og opdager dermed korruption fra disk, "
+ "controller eller driver, så snart siden læses. TORN_PAGE_DETECTION fanger kun en brøkdel, NONE intet.",
"Skift til CHECKSUM. Det gælder kun sider, der skrives efterfølgende, så kør også DBCC CHECKDB regelmæssigt.",
d => $"ALTER DATABASE {Name(d)} SET PAGE_VERIFY CHECKSUM;",
Docs.Learn("ALTER DATABASE SET options", "t-sql/statements/alter-database-transact-sql-set-options")),
new DatabaseSettingRule(
"SQLI-DB-005", "TRUSTWORTHY er slået til",
d => d.IsTrustworthy && !string.Equals(d.Name, "msdb", StringComparison.OrdinalIgnoreCase), Severity.Medium,
d => $"TRUSTWORTHY er slået til på {d.Name}",
"TRUSTWORTHY får SQL Server til at stole på kode i databasen, når den efterligner (impersonerer) brugere på "
+ "serverniveau. Ejes databasen af en sysadmin, kan en bruger med db_owner blive sysadmin.",
"Slå TRUSTWORTHY fra, og brug certifikat- eller modulsignering til de få moduler, der har brug for adgang uden for databasen.",
d => $"ALTER DATABASE {Name(d)} SET TRUSTWORTHY OFF;",
Docs.Learn("TRUSTWORTHY database property", "relational-databases/security/trustworthy-database-property")),
];
}
/// <summary>
/// Databases that are not usable.
/// </summary>
public sealed class DatabaseStateRule : Rule
{
public override string Id => "SQLI-DB-001";
public override string Title => "Database er ikke tilgængelig";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(context.Data.Get<DatabaseSnapshot>().Databases
.Where(d => d.State is "SUSPECT" or "RECOVERY_PENDING" or "EMERGENCY" or "OFFLINE")
.Select(d => CreateFinding(
d.State == "OFFLINE" ? Severity.Low : Severity.Critical,
$"{d.Name} er {d.State}",
d.State switch
{
"SUSPECT" => "SQL Server kunne ikke gennemføre recovery, typisk på grund af korruption eller en manglende/ødelagt fil. Databasen kan ikke bruges.",
"RECOVERY_PENDING" => "Recovery kunne ikke starte, typisk fordi en fil mangler, er låst eller disken er fuld. Databasen kan ikke bruges.",
"EMERGENCY" => "Databasen er sat i EMERGENCY-tilstand, normalt som led i en nødreparation. Kun sysadmin kan læse den.",
_ => "Databasen er taget offline. Det kan være bevidst, men den bliver hverken backet op eller tjekket.",
},
d.State == "OFFLINE"
? "Bekræft, at det er bevidst. Ellers: sæt den online eller fjern den."
: "Se SQL Server error log for årsagen. Gendan fra backup, hvis filerne er beskadigede. Kør ikke DBCC CHECKDB med REPAIR_ALLOW_DATA_LOSS uden at forstå konsekvensen: den sletter data.",
[new Evidence("Tilstand", d.State)],
subject: Databases.Subject(d))));
}
}
/// <summary>
/// Compatibility level below the engine's native level.
/// </summary>
public sealed class CompatibilityLevelRule : Rule
{
public override string Id => "SQLI-DB-006";
public override string Title => "Database kører med et ældre compatibility level";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var native = context.Instance.Version.Major * 10;
return Result(Databases.Online(context)
.Where(d => !d.IsSystemDatabase && d.CompatibilityLevel < native)
.Select(d => CreateFinding(
Severity.Low,
$"{d.Name} kører med compatibility level {d.CompatibilityLevel} (instansen understøtter {native})",
"Databasen bruger query optimizer og T-SQL-adfærd fra en ældre version og går glip af forbedringer som "
+ "adaptive joins, batch mode på rowstore og bedre kardinalitetsestimering.",
"Hæv compatibility level kontrolleret: slå Query Store til, saml en baseline, hæv niveauet, og brug Query Store til "
+ "at finde og låse eventuelle regredierede planer.",
[new Evidence("Compatibility level", d.CompatibilityLevel.ToString(CultureInfo.InvariantCulture)),
new Evidence("Instansens niveau", native.ToString(CultureInfo.InvariantCulture))],
subject: Databases.Subject(d),
references: Docs.Learn("Compatibility level", "relational-databases/databases/view-or-change-the-compatibility-level-of-a-database"))));
}
}
/// <summary>
/// Databases without a recent full backup.
/// </summary>
public sealed class FullBackupRule : Rule
{
public static readonly TimeSpan MaxAge = TimeSpan.FromDays(7);
public override string Id => "SQLI-DB-007";
public override string Title => "Database mangler en nylig fuld backup";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var findings = new List<Finding>();
foreach (var d in Databases.Online(context))
{
if (d.IsReplicaOrMirror || string.Equals(d.Name, "model", StringComparison.OrdinalIgnoreCase)
|| Databases.Age(context, d.CreateDate) < TimeSpan.FromDays(1))
{
continue;
}
var age = Databases.Age(context, d.LastFullBackup);
if (d.LastFullBackup is not null && age <= MaxAge)
{
continue;
}
var severity = d.LastFullBackup is null ? Severity.Critical : Severity.High;
var agNote = string.Empty;
if (d.IsInAvailabilityGroup)
{
severity = Severity.Medium;
agNote = " Databasen er i en availability group, så backups kan være taget på en anden replika; tjek den.";
}
findings.Add(CreateFinding(
severity,
d.LastFullBackup is null ? $"{d.Name} har aldrig haft en fuld backup" : $"{d.Name}: seneste fulde backup er {Format.Age(age)} gammel",
"Uden en nylig fuld backup kan databasen ikke gendannes efter en diskfejl, korruption eller en fejlagtig sletning, "
+ "og hver differentiel og log-backup er afhængig af den." + agNote,
"Planlæg fulde backups mindst ugentligt (dagligt for vigtige databaser) til et andet medie end databasefilerne, "
+ "og test en gendannelse jævnligt. En backup, der aldrig er testet, er ikke en backup.",
[new Evidence("Seneste fulde backup", Format.Date(d.LastFullBackup)), new Evidence("Recovery model", d.RecoveryModel)],
subject: Databases.Subject(d),
references: Docs.Learn("Backup and restore", "relational-databases/backup-restore/back-up-and-restore-of-sql-server-databases")));
}
return Result(findings);
}
}
/// <summary>
/// FULL/BULK_LOGGED databases without log backups: the log grows forever and point-in-time recovery is lost.
/// </summary>
public sealed class LogBackupRule : Rule
{
public static readonly TimeSpan MaxAge = TimeSpan.FromHours(24);
public override string Id => "SQLI-DB-008";
public override string Title => "Database i FULL recovery uden log-backups";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(Databases.Online(context)
.Where(d => d.RecoveryModel is "FULL" or "BULK_LOGGED" && !d.IsReplicaOrMirror && !d.IsReadOnly
&& !string.Equals(d.Name, "model", StringComparison.OrdinalIgnoreCase)
&& d.LastFullBackup is not null // Before the first full backup the log truncates as in SIMPLE.
&& (d.LastLogBackup is null || Databases.Age(context, d.LastLogBackup) > MaxAge))
.Select(d => CreateFinding(
d.IsInAvailabilityGroup ? Severity.Medium : Severity.High,
d.LastLogBackup is null
? $"{d.Name} er i {d.RecoveryModel} recovery, men har aldrig haft en log-backup"
: $"{d.Name}: seneste log-backup er {Format.Age(Databases.Age(context, d.LastLogBackup))} gammel",
$"I {d.RecoveryModel} recovery frigives plads i transaktionsloggen først, når loggen er backet op. Uden log-backups "
+ "vokser loggen, til disken er fuld, og al data siden seneste fulde backup går tabt ved et nedbrud.",
"Tag log-backups med et interval, der passer til det datatab, forretningen kan tåle (typisk 5-15 minutter). "
+ "Er point-in-time restore ikke nødvendigt, så skift til SIMPLE recovery i stedet.",
[new Evidence("Recovery model", d.RecoveryModel), new Evidence("Seneste log-backup", Format.Date(d.LastLogBackup)),
new Evidence("Seneste fulde backup", Format.Date(d.LastFullBackup))],
subject: Databases.Subject(d),
references: Docs.Learn("Transaction log backups", "relational-databases/backup-restore/transaction-log-backups-sql-server"))));
}
}
/// <summary>
/// DBCC CHECKDB not run recently.
/// </summary>
public sealed class CheckDbRule : Rule
{
public static readonly TimeSpan MaxAge = TimeSpan.FromDays(7);
public override string Id => "SQLI-DB-009";
public override string Title => "DBCC CHECKDB er ikke kørt for nylig";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.LastGoodCheckDbTime];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var findings = new List<Finding>();
foreach (var d in Databases.Online(context))
{
if (d.LastGoodCheckDb is not { } last || Databases.Age(context, d.CreateDate) < MaxAge)
{
continue;
}
var never = last.Year <= 1900;
var age = Databases.Age(context, last);
if (!never && age <= MaxAge)
{
continue;
}
findings.Add(CreateFinding(
never || age > TimeSpan.FromDays(30) ? Severity.High : Severity.Medium,
never ? $"DBCC CHECKDB har aldrig kørt fejlfrit på {d.Name}" : $"{d.Name}: seneste fejlfri DBCC CHECKDB er {Format.Age(age)} gammel",
"Korruption opdages først, når en beskadiget side læses, og kan brede sig til backups i ugevis. Jo længere siden "
+ "seneste CHECKDB, jo længere tilbage skal man måske gendanne, og jo større datatab.",
"Kør DBCC CHECKDB WITH NO_INFOMSGS, ALL_ERRORMSGS mindst ugentligt, gerne på en gendannet kopi, hvis vinduet er for kort. "
+ "Behold backups længere end intervallet mellem to CHECKDB.",
[new Evidence("Seneste fejlfri CHECKDB", never ? "aldrig" : Format.Date(last))],
subject: Databases.Subject(d),
references: Docs.Learn("DBCC CHECKDB", "t-sql/database-console-commands/dbcc-checkdb-transact-sql")));
}
return Result(findings);
}
}
/// <summary>
/// Pages SQL Server has recorded as corrupt.
/// </summary>
public sealed class SuspectPagesRule : Rule
{
public override string Id => "SQLI-DB-010";
public override string Title => "Korrupte sider registreret i suspect_pages";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(context.Data.Get<SuspectPageSnapshot>().Pages
.Where(p => p.IsUnresolved)
.GroupBy(p => p.DatabaseName, StringComparer.OrdinalIgnoreCase)
.Select(g => CreateFinding(
Severity.Critical,
$"{g.Key}: {g.Count()} korrupte sider",
"SQL Server har mødt sider med I/O-fejl, forkert kontrolsum eller torn pages (fejl 823/824/825). Det er næsten "
+ "altid et tegn på fejl i lagersystemet, og mere korruption følger ofte efter.",
"Kør DBCC CHECKDB straks for at se omfanget. Gendan de berørte sider eller hele databasen fra backup (page restore er "
+ "muligt i FULL recovery). Undersøg disk, controller og drivere, og tjek Windows' systemlog.",
g.Take(20).Select(p => new Evidence($"Fil {p.FileId}, side {p.PageId}", $"hændelse {p.EventType}, {p.ErrorCount} fejl, senest {Format.Date(p.LastUpdate)}")),
subject: $"database: {g.Key}",
references: Docs.Learn("suspect_pages", "relational-databases/backup-restore/manage-the-suspect-pages-table-sql-server"))));
}
}
/// <summary>
/// Too many virtual log files slow down recovery, restores and log backups.
/// </summary>
public sealed class VirtualLogFileRule : Rule
{
public const int Threshold = 1000;
public override string Id => "SQLI-DB-011";
public override string Title => "Transaktionsloggen har for mange VLF'er";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.LogInfoDmv];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(Databases.Online(context)
.Where(d => d.VirtualLogFileCount > Threshold)
.Select(d => CreateFinding(
d.VirtualLogFileCount > 10 * Threshold ? Severity.High : Severity.Medium,
$"{d.Name} har {Format.Number(d.VirtualLogFileCount!.Value)} VLF'er i transaktionsloggen",
"Loggen er vokset i mange små trin. Hver vækst opretter nye virtuelle logfiler, og mange VLF'er gør crash recovery, "
+ "restore, log-backups, replikering og AG-synkronisering markant langsommere.",
"Tag en log-backup (eller CHECKPOINT i SIMPLE), shrink logfilen, og lad den vokse igen i få, store trin til den "
+ "nødvendige størrelse. Sæt en fast autogrowth på fx 1024 MB.",
[new Evidence("VLF'er", Format.Number(d.VirtualLogFileCount!.Value))],
subject: Databases.Subject(d),
references: Docs.Learn("Transaction log architecture", "relational-databases/sql-server-transaction-log-architecture-and-management-guide"))));
}
}
/// <summary>
/// Autogrowth settings that cause many or unpredictable growths.
/// </summary>
public sealed class FileGrowthRule : Rule
{
public override string Id => "SQLI-DB-012";
public override string Title => "Uhensigtsmæssig autogrowth";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var findings = new List<Finding>();
foreach (var d in Databases.Online(context))
{
foreach (var f in d.Files.Where(f => f.Growth > 0 && f.SizeMb >= 1024))
{
string? problem = null;
if (f.IsPercentGrowth)
{
problem = $"vokser med {f.Growth} %, dvs. ca. {Format.Number(f.SizeMb * f.Growth / 100)} MB næste gang og mere hver gang";
}
else if (f.Growth < 64)
{
problem = $"vokser kun {f.Growth} MB ad gangen på en fil på {Format.Number(f.SizeMb)} MB";
}
if (problem is null)
{
continue;
}
findings.Add(CreateFinding(
Severity.Low,
$"{d.Name}/{f.LogicalName} {problem}",
"Procentvis vækst bliver større og langsommere, jo større filen er. Små faste trin giver mange vækster, "
+ "fragmentering og, for logfiler, mange VLF'er. Logfiler (og datafiler uden instant file initialization) "
+ "skal nulstilles ved vækst, så brugerne venter imens.",
"Sæt en fast vækst i MB, fx 256-1024 MB for datafiler og 512-1024 MB for logfiler, og størrelsesplanlæg "
+ "filerne, så autogrowth kun er et sikkerhedsnet.",
[new Evidence("Fil", f.PhysicalName), new Evidence("Størrelse (MB)", Format.Number(f.SizeMb)),
new Evidence("Autogrowth", f.IsPercentGrowth ? $"{f.Growth} %" : $"{f.Growth} MB")],
subject: $"file: {d.Name}/{f.LogicalName}",
fixScript: $"ALTER DATABASE [{d.Name.Replace("]", "]]", StringComparison.Ordinal)}] MODIFY FILE (NAME = N'{f.LogicalName.Replace("'", "''", StringComparison.Ordinal)}', FILEGROWTH = {(f.Type == "LOG" ? 1024 : 512)}MB);"));
}
}
return Result(findings);
}
}
/// <summary>
/// Files that will soon hit their maximum size.
/// </summary>
public sealed class FileMaxSizeRule : Rule
{
// The log file default "max" of 2 TB is effectively unlimited.
private const long EffectivelyUnlimitedMb = 2 * 1024 * 1024;
public override string Id => "SQLI-DB-013";
public override string Title => "Databasefil nærmer sig sin maksimale størrelse";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(Databases.Online(context).SelectMany(d => d.Files
.Where(f => f.MaxSizeMb > 0 && f.MaxSizeMb < EffectivelyUnlimitedMb && f.SizeMb >= f.MaxSizeMb * 0.9)
.Select(f => CreateFinding(
f.SizeMb >= f.MaxSizeMb ? Severity.Critical : Severity.High,
$"{d.Name}/{f.LogicalName} er {Format.Number(100.0 * f.SizeMb / f.MaxSizeMb)} % af sin maksimale størrelse",
"Filen kan ikke vokse ud over MAXSIZE. Når den er fuld, fejler alle ændringer (fejl 1105 for data, 9002 for log), "
+ "selvom der er plads på disken.",
"Hæv eller fjern MAXSIZE, eller frigør plads i filen. For logfiler: find ud af, hvad der forhindrer genbrug "
+ "(log_reuse_wait_desc i sys.databases).",
[new Evidence("Størrelse (MB)", Format.Number(f.SizeMb)), new Evidence("Maks. (MB)", Format.Number(f.MaxSizeMb))],
subject: $"file: {d.Name}/{f.LogicalName}"))));
}
}
/// <summary>
/// Query Store switched itself to read-only or error.
/// </summary>
public sealed class QueryStoreStateRule : Rule
{
public override string Id => "SQLI-DB-014";
public override string Title => "Query Store indsamler ikke længere data";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return Result(Databases.Online(context)
.Where(d => d.QueryStore is { } qs && qs.DesiredState == "READ_WRITE" && qs.ActualState != "READ_WRITE")
.Select(d =>
{
var qs = d.QueryStore!;
var full = (qs.ReadOnlyReason & 65536) != 0;
return CreateFinding(
qs.ActualState == "ERROR" ? Severity.High : Severity.Medium,
$"Query Store på {d.Name} er {qs.ActualState}" + (full ? " (fuld)" : string.Empty),
"Query Store er sat til READ_WRITE, men er selv gået over i " + qs.ActualState + ". Der indsamles ingen nye planer "
+ "eller statistikker, så planregressioner kan hverken opdages eller rettes med forcerede planer.",
full
? "Hæv MAX_STORAGE_SIZE_MB, eller sænk opbevaringstiden (STALE_QUERY_THRESHOLD_DAYS), og sæt QUERY_CAPTURE_MODE = AUTO. Sæt derefter OPERATION_MODE = READ_WRITE igen."
: "Se readonly_reason i sys.database_query_store_options. Ved ERROR: kør sp_query_store_consistency_check eller slå Query Store fra og til.",
[new Evidence("Tilstand", $"ønsket {qs.DesiredState}, faktisk {qs.ActualState}"),
new Evidence("Størrelse (MB)", $"{qs.CurrentStorageMb} af {qs.MaxStorageMb}"),
new Evidence("readonly_reason", qs.ReadOnlyReason.ToString(CultureInfo.InvariantCulture))],
subject: Databases.Subject(d),
references: Docs.Learn("Query Store", "relational-databases/performance/monitoring-performance-by-using-the-query-store"));
}));
}
}
/// <summary>
/// tempdb with too few, or unevenly sized, data files.
/// </summary>
public sealed class TempdbFilesRule : Rule
{
public override string Id => "SQLI-DB-015";
public override string Title => "tempdb har for få eller ulige store datafiler";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var files = context.Data.Get<DatabaseSnapshot>().TempdbFiles.Where(f => f.Type == "ROWS").ToList();
var cpus = context.Data.Get<ServerSnapshot>().LogicalCpuCount;
if (files.Count == 0)
{
return RuleEvaluation.Passed();
}
var recommended = Math.Min(8, cpus);
var findings = new List<Finding>();
var evidence = files.Select(f => new Evidence(f.LogicalName, $"{Format.Number(f.SizeMb)} MB, vækst {(f.IsPercentGrowth ? $"{f.Growth} %" : $"{f.Growth} MB")}")).ToList();
if (files.Count < recommended && files.Count < 4)
{
findings.Add(CreateFinding(
Severity.Medium,
$"tempdb har {files.Count} datafil(er) på en server med {cpus} logiske CPU'er",
"Hver datafil har egne allokeringssider (PFS, GAM, SGAM). Med få filer konkurrerer sessioner om de samme sider, "
+ "hvilket viser sig som PAGELATCH-ventetider på tempdb, når der oprettes mange midlertidige objekter.",
$"Brug {recommended} lige store datafiler (én pr. logisk CPU op til 8). Tilføj flere i trin af 4, hvis der stadig er contention.",
evidence,
subject: "database: tempdb",
references: Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")));
}
if (files.Count > 1 && (files.Select(f => f.SizeMb).Distinct().Count() > 1 || files.Select(f => (f.Growth, f.IsPercentGrowth)).Distinct().Count() > 1))
{
findings.Add(CreateFinding(
Severity.Low,
"tempdb's datafiler er ikke ens",
"SQL Server fordeler allokeringer efter ledig plads (proportional fill). Er filerne forskellige i størrelse eller "
+ "vækst, rammer det meste den største fil, og fordelen ved flere filer forsvinder.",
"Giv alle tempdb-datafiler samme størrelse og samme faste autogrowth.",
evidence,
subject: "database: tempdb",
references: Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")));
}
return Result(findings);
}
}

View file

@ -0,0 +1,66 @@
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine;
/// <summary>
/// Convenience base class for rules.
/// </summary>
public abstract class Rule : IRule
{
public abstract string Id { get; }
public abstract string Title { get; }
public virtual IReadOnlyCollection<Capability> RequiredCapabilities => [];
public abstract RuleEvaluation Evaluate(RuleContext context);
protected Finding CreateFinding(
Severity severity,
string title,
string explanation,
string recommendation,
IEnumerable<Evidence>? evidence = null,
string? subject = null,
string? fixScript = null,
params Reference[] references) => new()
{
RuleId = Id,
Severity = severity,
Title = title,
Subject = subject,
Explanation = explanation,
Recommendation = recommendation,
Evidence = evidence?.ToList() ?? [],
FixScript = fixScript,
References = references,
};
protected static RuleEvaluation Result(IEnumerable<Finding> findings)
{
var list = findings.ToArray();
return list.Length == 0 ? RuleEvaluation.Passed() : RuleEvaluation.Found(list);
}
}
/// <summary>
/// Links to Microsoft's documentation.
/// </summary>
internal static class Docs
{
public static Reference Learn(string title, string path) => new(title, new Uri("https://learn.microsoft.com/sql/" + path));
}
internal static class Format
{
private static readonly System.Globalization.CultureInfo Danish = System.Globalization.CultureInfo.GetCultureInfo("da-DK");
public static string Number(double value, int decimals = 0) => value.ToString("N" + decimals, Danish);
public static string Number(long value) => value.ToString("N0", Danish);
public static string Date(DateTime? value) => value is { } v ? v.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) : "aldrig";
public static string Age(TimeSpan age) => age.TotalDays >= 2
? $"{Number(age.TotalDays)} dage"
: age.TotalHours >= 2 ? $"{Number(age.TotalHours)} timer" : $"{Number(age.TotalMinutes)} minutter";
}

View file

@ -1,5 +1,16 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Instances; using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine; namespace SQLInsight.Rules.Engine;
public sealed record RuleContext(InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now); /// <param name="Data">
/// The latest collected data. Rules read it with <see cref="CollectedData.Get{T}"/>; missing data makes the rule
/// not measurable instead of passing.
/// </param>
public sealed record RuleContext(InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now, CollectedData Data)
{
public RuleContext(InstanceInfo instance, InstanceCapabilities capabilities, DateTimeOffset now)
: this(instance, capabilities, now, new CollectedData())
{
}
}

View file

@ -1,3 +1,4 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Instances; using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine; namespace SQLInsight.Rules.Engine;
@ -55,6 +56,16 @@ public sealed class RuleEngine
Reason = evaluation.Reason, Reason = evaluation.Reason,
}; };
} }
catch (DataUnavailableException ex)
{
return new RuleResult
{
RuleId = rule.Id,
RuleTitle = rule.Title,
Outcome = RuleOutcome.NotMeasurable,
Reason = ex.Message,
};
}
#pragma warning disable CA1031 // One faulty rule must not stop the others; the failure is reported, not hidden. #pragma warning disable CA1031 // One faulty rule must not stop the others; the failure is reported, not hidden.
catch (Exception ex) catch (Exception ex)
#pragma warning restore CA1031 #pragma warning restore CA1031

View file

@ -0,0 +1,178 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Performance;
internal static class FileIo
{
public static FileIoDelta Delta(RuleContext context) =>
context.Data.Get<CumulativeSample<FileIoSnapshot, FileIoDelta>>().Delta
?? throw new DataUnavailableException("I/O-latens kræver to målinger; første måling er taget (eller instansen er genstartet).");
}
/// <summary>
/// Slow reads from data files.
/// </summary>
public sealed class DataFileReadLatencyRule : Rule
{
public const int MinReads = 1000;
public override string Id => "SQLI-IO-001";
public override string Title => "Høj læselatens på datafiler";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var delta = FileIo.Delta(context);
return Result(delta.Files
.Where(f => f.FileType == "ROWS" && f.Reads >= MinReads && f.AverageReadLatencyMs >= 20)
.Select(f => CreateFinding(
f.AverageReadLatencyMs >= 50 ? Severity.High : Severity.Medium,
$"{f.DatabaseName}: {Format.Number(f.AverageReadLatencyMs!.Value, 1)} ms i gennemsnit pr. læsning",
"Læsninger fra datafilen tager i gennemsnit over 20 ms. På moderne storage (SSD/SAN) bør de ligge under 5-10 ms. "
+ "Langsomme læsninger rammer direkte alle forespørgsler, der ikke finder data i buffer pool.",
"Sammenlign med latensen i storage-laget (Windows' Avg. Disk sec/Read, SAN-statistik) for at se, om ventetiden "
+ "opstår i storage eller undervejs (HBA, multipath, VM-host). Undersøg også, om mængden af læsninger kan reduceres "
+ "med bedre indexes eller mere hukommelse.",
[new Evidence("Fil", f.PhysicalName), new Evidence("Læsninger", Format.Number(f.Reads)),
new Evidence("Gns. latens", $"{Format.Number(f.AverageReadLatencyMs!.Value, 1)} ms"),
new Evidence("Interval", $"{Format.Number((delta.To - delta.From).TotalMinutes)} min")],
subject: $"file: {f.DatabaseName}/{f.FileId}",
references: Docs.Learn("sys.dm_io_virtual_file_stats", "relational-databases/system-dynamic-management-views/sys-dm-io-virtual-file-stats-transact-sql"))));
}
}
/// <summary>
/// Slow writes to transaction log files.
/// </summary>
public sealed class LogFileWriteLatencyRule : Rule
{
public const int MinWrites = 1000;
public override string Id => "SQLI-IO-002";
public override string Title => "Høj skrivelatens på logfiler";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var delta = FileIo.Delta(context);
return Result(delta.Files
.Where(f => f.FileType == "LOG" && f.Writes >= MinWrites && f.AverageWriteLatencyMs >= 5)
.Select(f => CreateFinding(
f.AverageWriteLatencyMs >= 15 ? Severity.High : Severity.Medium,
$"{f.DatabaseName}: {Format.Number(f.AverageWriteLatencyMs!.Value, 1)} ms i gennemsnit pr. logskrivning",
"Hver commit venter på, at loggen er skrevet. Logskrivninger bør tage 1-2 ms; over 5 ms gør alle skrivende "
+ "transaktioner mærkbart langsommere.",
"Placér logfilen på storage med lav skrivelatens og write cache med batteribackup, adskilt fra datafilerne. "
+ "Tjek, om andre filer eller VM'er deler samme volumen.",
[new Evidence("Fil", f.PhysicalName), new Evidence("Skrivninger", Format.Number(f.Writes)),
new Evidence("Gns. latens", $"{Format.Number(f.AverageWriteLatencyMs!.Value, 1)} ms")],
subject: $"file: {f.DatabaseName}/{f.FileId}",
references: Docs.Learn("sys.dm_io_virtual_file_stats", "relational-databases/system-dynamic-management-views/sys-dm-io-virtual-file-stats-transact-sql"))));
}
}
/// <summary>
/// Sustained high CPU, split into SQL Server's own and other processes'.
/// </summary>
public sealed class CpuRule : Rule
{
public const int Minutes = 15;
public override string Id => "SQLI-CPU-001";
public override string Title => "Vedvarende højt CPU-forbrug";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var samples = context.Data.Get<CpuUtilization>().Samples.TakeLast(Minutes).ToList();
if (samples.Count < 5)
{
throw new DataUnavailableException("CPU-historikken har endnu for få målinger (scheduler monitor skriver én pr. minut).");
}
var sql = samples.Average(s => s.SqlServerPercent);
var other = samples.Average(s => s.OtherProcessesPercent);
var findings = new List<Finding>();
var evidence = new[]
{
new Evidence("Periode", $"{samples.Count} min"),
new Evidence("SQL Server gns./maks.", $"{Format.Number(sql)} % / {samples.Max(s => s.SqlServerPercent)} %"),
new Evidence("Andre processer gns.", $"{Format.Number(other)} %"),
};
if (sql >= 75)
{
findings.Add(CreateFinding(
sql >= 90 ? Severity.High : Severity.Medium,
$"SQL Server har brugt {Format.Number(sql)} % CPU i gennemsnit de seneste {samples.Count} minutter",
"Vedvarende højt CPU-forbrug giver længere svartider for alle og efterlader ingen reserve til spidsbelastning.",
"Find de forespørgsler, der bruger mest CPU (Query Store: Top Resource Consuming Queries, eller sys.dm_exec_query_stats "
+ "sorteret efter total_worker_time). Typiske årsager: manglende indexes, implicitte konverteringer, "
+ "planregressioner og for høj parallelisme.",
evidence,
subject: "cpu: sqlserver"));
}
if (other >= 25)
{
findings.Add(CreateFinding(
Severity.Medium,
$"Andre processer end SQL Server har brugt {Format.Number(other)} % CPU",
"En betydelig del af CPU'en bruges af andre processer på serveren. Det tager kapacitet fra SQL Server, "
+ "og SQL Servers egne målinger forklarer ikke langsomheden.",
"Find processen i Jobliste/Performance Monitor. Typiske syndere: antivirus uden undtagelser for databasefiler, "
+ "SSIS/SSAS/SSRS på samme server, backup-agenter. På en VM: tjek også CPU ready time på hosten.",
evidence,
subject: "cpu: other"));
}
return Result(findings);
}
}
/// <summary>
/// Queries waiting for a memory grant before they can even start.
/// </summary>
public sealed class MemoryGrantsPendingRule : Rule
{
public override string Id => "SQLI-MEM-001";
public override string Title => "Forespørgsler venter på hukommelse";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var counters = context.Data.Get<CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>>().Snapshot;
long semaphoreMs = 0;
if (context.Data.TryGet<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>>(out var waits) && waits.Delta is { } delta)
{
semaphoreMs = delta.Waits.Where(w => w.WaitType == "RESOURCE_SEMAPHORE").Sum(w => w.WaitTimeMs);
}
var pending = counters.MemoryGrantsPending ?? 0;
if (pending == 0 && semaphoreMs < 10_000)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.High,
Title,
"Forespørgsler med sorteringer og hash joins skal have tildelt hukommelse, før de starter. Når der ikke er nok, "
+ "står de i kø (RESOURCE_SEMAPHORE) og ser ud til at hænge uden at bruge CPU. Ofte skyldes det få forespørgsler "
+ "med enorme, overestimerede tildelinger.",
"Find de største tildelinger i sys.dm_exec_query_memory_grants (granted_memory_kb mod used_memory_kb). "
+ "Ret dårlige kardinalitetsestimater (statistik, parameter sniffing), eller begræns med Resource Governor eller "
+ "MAX_GRANT_PERCENT-hint. Memory grant feedback (2019+) hjælper ved gentagne forespørgsler.",
[new Evidence("Memory Grants Pending", pending.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new Evidence("Memory Grants Outstanding", counters.MemoryGrantsOutstanding?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "?"),
new Evidence("RESOURCE_SEMAPHORE i intervallet", $"{Format.Number(semaphoreMs)} ms")],
references: Docs.Learn("sys.dm_exec_query_memory_grants", "relational-databases/system-dynamic-management-views/sys-dm-exec-query-memory-grants-transact-sql")));
}
}

View file

@ -0,0 +1,235 @@
using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Performance;
/// <summary>
/// Wait statistics for the last interval, summarized per category.
/// </summary>
internal sealed class WaitProfile
{
private WaitProfile(WaitStatsDelta delta)
{
Delta = delta;
Seconds = Math.Max(1, delta.Interval.TotalSeconds);
}
public WaitStatsDelta Delta { get; }
public double Seconds { get; }
public static WaitProfile From(RuleContext context)
{
var sample = context.Data.Get<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>>();
return new WaitProfile(sample.Delta
?? throw new DataUnavailableException("Ventetider kræver to målinger; første måling er taget (eller instansen er genstartet)."));
}
public IEnumerable<WaitStat> In(WaitCategory category) => Delta.Waits.Where(w => WaitCatalog.Categorize(w.WaitType) == category);
public long WaitMs(WaitCategory category) => In(category).Sum(w => w.WaitTimeMs);
/// <summary>Average number of tasks waiting on this category at any moment (wait ms per wall-clock ms).</summary>
public double AverageWaiters(WaitCategory category) => WaitMs(category) / (Seconds * 1000);
public double Share(WaitCategory category) => Delta.TotalWaitTimeMs == 0 ? 0 : (double)WaitMs(category) / Delta.TotalWaitTimeMs;
public IEnumerable<Evidence> Evidence(WaitCategory category) =>
new[]
{
new Evidence("Interval", $"{Format.Number(Seconds / 60)} min ({Delta.From:HH:mm}–{Delta.To:HH:mm} UTC)"),
new Evidence("Andel af alle ventetider", $"{Format.Number(Share(category) * 100)} %"),
new Evidence("Gns. ventende tasks", Format.Number(AverageWaiters(category), 2)),
}.Concat(In(category).Take(5).Select(w => new Evidence(w.WaitType,
$"{Format.Number(w.WaitTimeMs)} ms, {Format.Number(w.WaitingTasks)} waits, gns. {Format.Number(w.WaitingTasks == 0 ? 0 : (double)w.WaitTimeMs / w.WaitingTasks, 1)} ms")));
}
/// <summary>
/// A wait category that dominates the interval. Share alone is not enough (an idle server has 100 % of something),
/// so the average number of waiting tasks must also be meaningful.
/// </summary>
public sealed class DominantWaitRule(
string id,
WaitCategory category,
double minShare,
double minAverageWaiters,
double highAverageWaiters,
string title,
string explanation,
string recommendation,
Reference reference) : Rule
{
public override string Id => id;
public override string Title => title;
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var waits = WaitProfile.From(context);
var waiters = waits.AverageWaiters(category);
if (waits.Share(category) < minShare || waiters < minAverageWaiters)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
waiters >= highAverageWaiters ? Severity.High : Severity.Medium,
title,
explanation,
recommendation,
waits.Evidence(category),
references: reference));
}
}
public static class WaitRules
{
private static readonly Reference WaitStatsDocs = Docs.Learn("sys.dm_os_wait_stats", "relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql");
public static IEnumerable<IRule> Create() =>
[
new ThreadPoolWaitRule(),
new WriteLogLatencyRule(),
new SignalWaitRule(),
new DominantWaitRule(
"SQLI-WAIT-003", WaitCategory.Locks, 0.2, 0.5, 5,
"Sessioner venter meget på låse",
"En stor del af ventetiden er LCK_M_*: sessioner venter på låse, som andre sessioner holder. Det skyldes typisk "
+ "lange transaktioner, manglende indexes (så opdateringer låser flere rækker end nødvendigt) eller "
+ "læsninger, der blokeres af skrivninger.",
"Se blocking-fundene for, hvem der blokerer. Hold transaktioner korte, tilføj indexes til WHERE-betingelserne i "
+ "opdateringer, og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres af skrivere (test først: det "
+ "belaster tempdb og ændrer adfærd for kode, der er afhængig af låse).",
Docs.Learn("Transaction locking guide", "relational-databases/sql-server-transaction-locking-and-row-versioning-guide")),
new DominantWaitRule(
"SQLI-WAIT-004", WaitCategory.BufferIo, 0.3, 1, 8,
"Forespørgsler venter meget på at læse data fra disk",
"PAGEIOLATCH-ventetider betyder, at sider skal læses fra disk, fordi de ikke er i buffer pool. Årsagen er "
+ "enten for lidt hukommelse, forespørgsler der læser langt mere end nødvendigt (scans, manglende indexes), "
+ "eller langsom storage. Høj ventetid er et symptom; årsagen skal findes.",
"Tjek først I/O-latens pr. fil (hvis den er lav, er problemet mængden af læsninger, ikke disken). Find de "
+ "forespørgsler, der læser mest, i Query Store eller sys.dm_exec_query_stats, og se på deres planer og indexes. "
+ "Tjek derefter, om max server memory er sat fornuftigt.",
WaitStatsDocs),
new DominantWaitRule(
"SQLI-WAIT-006", WaitCategory.Parallelism, 0.3, 2, 16,
"Parallelle forespørgsler venter meget på hinanden",
"CXPACKET er i sig selv normalt for parallelle planer. Når det dominerer, betyder det typisk mange små "
+ "forespørgsler, der unødigt går parallelt, eller skæv fordeling af rækker mellem trådene.",
"Tjek cost threshold for parallelism og MAXDOP (se konfigurationsfund). Find de parallelle forespørgsler med "
+ "højest CPU i Query Store; ofte fjerner et bedre index behovet for parallelisme.",
WaitStatsDocs),
new DominantWaitRule(
"SQLI-WAIT-007", WaitCategory.PageLatch, 0.2, 0.5, 4,
"Contention på sider i hukommelsen (PAGELATCH)",
"PAGELATCH-ventetider er konkurrence om sider, der allerede er i hukommelsen. De to klassiske årsager er "
+ "allokeringssider i tempdb (mange midlertidige objekter) og 'last page insert' på et index med stigende nøgle.",
"Tjek wait_resource i sys.dm_exec_requests under belastning: 2:x:y betyder tempdb (tilføj lige store datafiler, "
+ "se tempdb-fund). Ved en brugertabel: overvej OPTIMIZE_FOR_SEQUENTIAL_KEY (2019+) eller en anden nøgle.",
Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")),
];
}
/// <summary>
/// THREADPOOL: SQL Server ran out of worker threads. New connections hang; this is an outage in progress.
/// </summary>
public sealed class ThreadPoolWaitRule : Rule
{
public override string Id => "SQLI-WAIT-001";
public override string Title => "SQL Server løber tør for worker threads";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var waits = WaitProfile.From(context);
if (waits.WaitMs(Core.Data.WaitCategory.WorkerThreads) < 1000)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Critical,
Title,
"THREADPOOL-ventetider betyder, at nye opgaver ikke kunne få en worker thread. Imens hænger nye forbindelser og "
+ "forespørgsler, og serveren virker død udefra. Årsagen er næsten altid massiv blocking (mange sessioner, der "
+ "venter på den samme lås) eller mange samtidige parallelle forespørgsler.",
"Find head blocker (se blocking-fund) og løs den. Brug Dedicated Admin Connection (ADMIN:), hvis almindelige "
+ "forbindelser hænger. Hæv ikke 'max worker threads' som løsning; det flytter kun problemet.",
waits.Evidence(Core.Data.WaitCategory.WorkerThreads),
references: Docs.Learn("max worker threads", "database-engine/configure-windows/configure-the-max-worker-threads-server-configuration-option")));
}
}
/// <summary>
/// Slow transaction log writes, measured as average WRITELOG wait per commit.
/// </summary>
public sealed class WriteLogLatencyRule : Rule
{
public override string Id => "SQLI-WAIT-005";
public override string Title => "Commits venter på langsom skrivning til transaktionsloggen";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var waits = WaitProfile.From(context);
var writelog = waits.Delta.Waits.FirstOrDefault(w => w.WaitType == "WRITELOG");
if (writelog is null || writelog.WaitingTasks < 1000)
{
return RuleEvaluation.Passed();
}
var average = (double)writelog.WaitTimeMs / writelog.WaitingTasks;
if (average < 5)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
average >= 15 ? Severity.High : Severity.Medium,
$"{Title} (gns. {Format.Number(average, 1)} ms)",
"Hver commit venter, til loggen er skrevet til disk. Gennemsnitligt over 5 ms pr. WRITELOG-wait gør alle "
+ "skrivende transaktioner langsommere og begrænser, hvor mange commits serveren kan nå pr. sekund.",
"Tjek skrivelatens på logfilerne (se I/O-fund). Placér loggen på hurtig storage med lav latens, adskilt fra datafiler. "
+ "Undgå mange små transaktioner i løkker; saml dem i batches. Overvej delayed durability kun, hvis datatab ved nedbrud er acceptabelt.",
waits.Evidence(Core.Data.WaitCategory.TransactionLog),
references: Docs.Learn("Transaction log architecture", "relational-databases/sql-server-transaction-log-architecture-and-management-guide")));
}
}
/// <summary>
/// High signal wait ratio: tasks are ready to run but wait for a CPU.
/// </summary>
public sealed class SignalWaitRule : Rule
{
public override string Id => "SQLI-WAIT-002";
public override string Title => "Tasks venter på CPU (høj signal wait)";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var waits = WaitProfile.From(context);
var total = waits.Delta.TotalWaitTimeMs;
var ratio = total == 0 ? 0 : (double)waits.Delta.TotalSignalWaitTimeMs / total;
if (ratio < 0.25 || total / (waits.Seconds * 1000) < 1)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
ratio >= 0.4 ? Severity.High : Severity.Medium,
$"{Title}: {Format.Number(ratio * 100)} % af ventetiden",
"Signal wait er tiden fra en task har fået sin ressource, til den får en CPU igen. Over ca. 25 % betyder, at "
+ "schedulers er overbelastede: der er mere arbejde end CPU.",
"Find forespørgslerne med højest CPU-forbrug i Query Store eller sys.dm_exec_query_stats og optimér dem "
+ "(manglende indexes, implicitte konverteringer, skalare funktioner). Tjek MAXDOP og cost threshold for parallelism. "
+ "Mere CPU er først svaret, når forespørgslerne er effektive.",
[new Evidence("Signal wait", $"{Format.Number(ratio * 100)} %")],
references: Docs.Learn("sys.dm_os_wait_stats", "relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql")));
}
}

View file

@ -5,6 +5,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" /> <ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.Rules.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Lifecycle\support-lifecycle.json" LogicalName="SQLInsight.Rules.support-lifecycle.json" /> <EmbeddedResource Include="Lifecycle\support-lifecycle.json" LogicalName="SQLInsight.Rules.support-lifecycle.json" />
</ItemGroup> </ItemGroup>

View file

@ -0,0 +1,69 @@
using System.Xml.Linq;
using SQLInsight.Collectors.Concurrency;
namespace SQLInsight.Collectors.Tests;
public class DeadlockParserTests
{
private const string Graph = """
<deadlock>
<victim-list><victimProcess id="process1" /></victim-list>
<process-list>
<process id="process1" spid="60" waitresource="KEY: 5:72057594043236352 (8194443284a0)" lockMode="U" isolationlevel="read committed (2)"
clientapp="Sales.Api" hostname="WEB01" loginname="app" currentdb="5" currentdbname="Sales">
<inputbuf> UPDATE dbo.Orders SET Status = 2 WHERE Id = @id </inputbuf>
</process>
<process id="process2" spid="61" waitresource="KEY: 5:72057594043236352 (61a06abd401c)" lockMode="U" currentdb="5">
<inputbuf>UPDATE dbo.Orders SET Status = 3 WHERE Id = @id</inputbuf>
</process>
</process-list>
<resource-list />
</deadlock>
""";
[Fact]
public void Parses_event_file_format()
{
var evt = XElement.Parse($"""
<event name="xml_deadlock_report" timestamp="2026-09-29T10:15:30.123Z">
<data name="xml_report"><value>{Graph}</value></data>
</event>
""");
var deadlock = DeadlockParser.ParseEvent(evt)!;
Assert.Equal(new DateTimeOffset(2026, 9, 29, 10, 15, 30, 123, TimeSpan.Zero), deadlock.Time);
Assert.Equal(2, deadlock.Processes.Count);
var victim = Assert.Single(deadlock.Processes, p => p.IsVictim);
Assert.Equal(60, victim.SessionId);
Assert.Equal("Sales", victim.DatabaseName);
Assert.Equal("Sales.Api", victim.ClientApp);
Assert.Equal("UPDATE dbo.Orders SET Status = 2 WHERE Id = @id", victim.InputBuffer);
Assert.Equal("5", deadlock.Processes[1].DatabaseName); // no currentdbname on older builds
Assert.StartsWith("<deadlock>", deadlock.GraphXml, StringComparison.Ordinal);
}
[Fact]
public void Parses_sql2008_escaped_graph()
{
// SQL Server 2008 RTM–SP1 put the graph in the ring buffer as escaped text.
var escaped = System.Security.SecurityElement.Escape(Graph);
var evt = XElement.Parse($"""
<event name="xml_deadlock_report" timestamp="2026-09-29T10:15:30Z">
<data name="xml_report"><type name="unicode_string" /><value>{escaped}</value></data>
</event>
""");
var deadlock = DeadlockParser.ParseEvent(evt)!;
Assert.Equal(2, deadlock.Processes.Count);
}
[Fact]
public void Garbage_is_ignored()
{
var evt = XElement.Parse("""<event name="xml_deadlock_report" timestamp="2026-09-29T10:15:30Z"><data name="xml_report"><value>not xml</value></data></event>""");
Assert.Null(DeadlockParser.ParseEvent(evt));
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,68 @@
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Collectors.Databases;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Tests;
/// <summary>
/// The version-specific SQL that cannot be run in CI (SQL Server 2000–2016 have no containers).
/// </summary>
public class SqlSelectionTests
{
private static InstanceInfo Instance(string version) => new()
{
ServerName = "x",
Version = SqlServerVersion.Parse(version),
Edition = "Standard",
EngineEdition = EngineEdition.Standard,
};
[Theory]
[InlineData("8.00.2039", "master.dbo.sysprocesses WHERE spid = 1")]
[InlineData("9.00.5000.00", "sys.databases WHERE database_id = 2")]
[InlineData("10.50.6000.34", "sqlserver_start_time")]
public void Start_time_source_per_version(string version, string expected)
{
Assert.Contains(expected, Db.StartTimeExpression(SqlServerVersion.Parse(version)), StringComparison.Ordinal);
}
[Fact]
public void Blocking_on_2008_counts_transactions_without_open_transaction_count()
{
var sql = BlockingCollector.BuildSql2005(SqlServerVersion.Parse("10.50.6000.34"));
Assert.Contains("sys.dm_tran_session_transactions", sql, StringComparison.Ordinal);
Assert.DoesNotContain("open_transaction_count", sql, StringComparison.Ordinal);
Assert.DoesNotContain("s.database_id", sql, StringComparison.Ordinal);
}
[Fact]
public void Databases_on_2012_detect_availability_groups_and_skip_checkdb_property()
{
var instance = Instance("11.0.7001.0");
var sql = DatabaseCollector.BuildSql2005(instance, InstanceCapabilities.From(instance));
Assert.Contains("sys.dm_hadr_database_replica_states", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LastGoodCheckDbTime", sql, StringComparison.Ordinal);
}
[Fact]
public void Databases_on_2005_do_not_touch_hadr()
{
var instance = Instance("9.00.5000.00");
var sql = DatabaseCollector.BuildSql2005(instance, InstanceCapabilities.From(instance));
Assert.DoesNotContain("hadr", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("replica_id", sql, StringComparison.Ordinal);
}
[Fact]
public void Deadlock_file_read_continues_from_last_position_with_escaped_literal()
{
var sql = DeadlockCollector.BuildFileSql("/var/opt/mssql/log/system_health_0_1'2.xel", 12345);
Assert.Contains("N'/var/opt/mssql/log/system_health_0_1''2.xel', 12345", sql, StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,126 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Core.Tests;
public class DeltaTests
{
private static readonly DateTime Start = new(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc);
private static readonly DateTimeOffset T0 = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
private static WaitStatsSnapshot Waits(DateTimeOffset at, DateTime start, params WaitStat[] waits) =>
new() { CapturedAt = at, SqlServerStartTime = start, Waits = waits };
[Fact]
public void Wait_delta_subtracts_and_drops_benign_waits()
{
var before = Waits(T0, Start, new WaitStat("LCK_M_X", 10, 1000, 10), new WaitStat("LAZYWRITER_SLEEP", 5, 5000, 0));
var after = Waits(T0.AddMinutes(5), Start, new WaitStat("LCK_M_X", 15, 4000, 20), new WaitStat("LAZYWRITER_SLEEP", 9, 90000, 0),
new WaitStat("WRITELOG", 100, 300, 5));
var delta = WaitStatsDelta.Compute(before, after)!;
Assert.Equal(TimeSpan.FromMinutes(5), delta.Interval);
Assert.Equal([new WaitStat("LCK_M_X", 5, 3000, 10), new WaitStat("WRITELOG", 100, 300, 5)], delta.Waits);
}
[Fact]
public void Wait_delta_is_null_after_restart()
{
var before = Waits(T0, Start, new WaitStat("LCK_M_X", 10, 1000, 10));
var after = Waits(T0.AddMinutes(5), Start.AddDays(1), new WaitStat("LCK_M_X", 1, 10, 0));
Assert.Null(WaitStatsDelta.Compute(before, after));
}
[Fact]
public void Wait_delta_is_null_when_stats_were_cleared()
{
var before = Waits(T0, Start, new WaitStat("LCK_M_X", 10, 1000, 10));
var after = Waits(T0.AddMinutes(5), Start, new WaitStat("LCK_M_X", 2, 50, 0));
Assert.Null(WaitStatsDelta.Compute(before, after));
}
[Fact]
public void First_sample_has_no_delta()
{
Assert.Null(WaitStatsDelta.Compute(null, Waits(T0, Start)));
}
[Theory]
[InlineData("SLEEP_TASK", true)]
[InlineData("QDS_PERSIST_TASK_MAIN_LOOP_SLEEP", true)]
[InlineData("CXCONSUMER", true)]
[InlineData("Total", true)]
[InlineData("CXPACKET", false)]
[InlineData("PAGEIOLATCH_SH", false)]
public void Benign_waits(string waitType, bool benign)
{
Assert.Equal(benign, WaitCatalog.IsBenign(waitType));
}
[Theory]
[InlineData("LCK_M_IX", WaitCategory.Locks)]
[InlineData("PAGEIOLATCH_EX", WaitCategory.BufferIo)]
[InlineData("PAGELATCH_UP", WaitCategory.PageLatch)]
[InlineData("THREADPOOL", WaitCategory.WorkerThreads)]
[InlineData("WRITELOG", WaitCategory.TransactionLog)]
[InlineData("HADR_SYNC_COMMIT", WaitCategory.AvailabilityGroups)]
[InlineData("SOMETHING_NEW", WaitCategory.Other)]
public void Wait_categories(string waitType, WaitCategory category)
{
Assert.Equal(category, WaitCatalog.Categorize(waitType));
}
private static FileIoStat File(string db, int id, long reads, long stall) => new()
{
DatabaseName = db, FileId = id, FileType = "ROWS", PhysicalName = "x", Reads = reads, BytesRead = 0, ReadStallMs = stall,
Writes = 0, BytesWritten = 0, WriteStallMs = 0,
};
[Fact]
public void File_io_delta_skips_files_that_restarted_from_zero()
{
var before = new FileIoSnapshot { CapturedAt = T0, SqlServerStartTime = Start, Files = [File("A", 1, 100, 1000), File("B", 1, 500, 500)] };
var after = new FileIoSnapshot
{
CapturedAt = T0.AddMinutes(5),
SqlServerStartTime = Start,
Files = [File("A", 1, 300, 5000), File("B", 1, 10, 10), File("C", 1, 50, 50)], // B restored, C new
};
var delta = FileIoDelta.Compute(before, after)!;
var a = Assert.Single(delta.Files);
Assert.Equal(("A", 200L, 4000L), (a.DatabaseName, a.Reads, a.ReadStallMs));
Assert.Equal(20, a.AverageReadLatencyMs);
}
[Fact]
public void Counter_rates()
{
var before = new PerformanceCounterSnapshot { CapturedAt = T0, SqlServerStartTime = Start, BatchRequests = 1000 };
var after = before with { CapturedAt = T0.AddSeconds(10), BatchRequests = 3000 };
Assert.Equal(200, PerformanceCounterRates.Compute(before, after)!.BatchRequestsPerSecond);
}
[Fact]
public void Head_blockers_are_blockers_that_are_not_blocked()
{
var snapshot = new BlockingSnapshot
{
CapturedAt = T0,
Sessions =
[
new SessionInfo { SessionId = 51 },
new SessionInfo { SessionId = 60, BlockingSessionId = 51 },
new SessionInfo { SessionId = 61, BlockingSessionId = 60 },
new SessionInfo { SessionId = 70 },
new SessionInfo { SessionId = 71, BlockingSessionId = 70 },
],
};
Assert.Equal([51, 70], snapshot.HeadBlockers.Select(s => s.SessionId));
}
}

View file

@ -0,0 +1,47 @@
using SQLInsight.Collectors;
using SQLInsight.Connectivity;
using SQLInsight.Core.Instances;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// A monitoring connection as the least-privilege agent login, opened exactly like the agent does.
/// </summary>
internal sealed class AgentSession : IAsyncDisposable
{
private readonly OpenedConnection _opened;
private AgentSession(OpenedConnection opened, InstanceInfo instance)
{
_opened = opened;
Instance = instance;
Capabilities = InstanceCapabilities.From(instance);
}
public InstanceInfo Instance { get; }
public InstanceCapabilities Capabilities { get; }
public static async Task<AgentSession> OpenAsync(SqlServerFixture sql)
{
var opened = await new SqlConnectionFactory().OpenAsync(new ConnectionTarget
{
Server = sql.Server,
Authentication = AuthenticationMode.SqlLogin,
UserName = SqlServerFixture.AgentLogin,
Password = SqlServerFixture.AgentPassword,
Provider = ConnectionProvider.SqlClient,
TrustServerCertificate = true,
});
var instance = await InstanceProbe.DetectAsync(opened.Connection);
return new AgentSession(opened, instance);
}
public async Task<T> CollectAsync<T>(ICollector collector, T? previous = null)
where T : class
{
var context = new CollectorContext(_opened.Connection, Instance, Capabilities, DateTimeOffset.UtcNow, previous, CancellationToken.None);
return (T)await collector.CollectAsync(context);
}
public ValueTask DisposeAsync() => _opened.DisposeAsync();
}

View file

@ -0,0 +1,244 @@
using Microsoft.Data.SqlClient;
using SQLInsight.Collectors;
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Collectors.Databases;
using SQLInsight.Collectors.Instance;
using SQLInsight.Collectors.Performance;
using SQLInsight.Core.Data;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// Every collector against real SQL Server, as the least-privilege agent login.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class CollectorTests(SqlServerFixture sql)
{
[Fact]
public async Task Every_built_in_collector_runs_with_least_privilege()
{
await using var session = await AgentSession.OpenAsync(sql);
foreach (var collector in BuiltInCollectors.Create())
{
var result = await session.CollectAsync<object>(collector);
Assert.True(collector.DataType.IsInstanceOfType(result), $"{collector.Name} returned {result.GetType().Name}");
}
}
[Fact]
public async Task Server_reports_cpu_memory_and_start_time()
{
await using var session = await AgentSession.OpenAsync(sql);
var server = await session.CollectAsync<ServerSnapshot>(new ServerCollector());
Assert.True(server.LogicalCpuCount > 0);
Assert.True(server.PhysicalMemoryMb > 0);
Assert.True(server.VisibleOnlineSchedulers > 0);
Assert.True(server.SqlServerStartTime < DateTime.Now.AddMinutes(1));
}
[Fact]
public async Task Configuration_contains_well_known_options()
{
await using var session = await AgentSession.OpenAsync(sql);
var config = await session.CollectAsync<ConfigurationSnapshot>(new ConfigurationCollector());
Assert.NotNull(config.Find("max server memory (MB)"));
Assert.Equal(5, config.Find("cost threshold for parallelism")!.ValueInUse);
}
[Fact]
public async Task Databases_include_settings_files_backups_and_query_store()
{
await sql.ExecuteAsSaAsync($"BACKUP DATABASE [{SqlServerFixture.TestDatabase}] TO DISK = N'/tmp/qs.bak' WITH INIT, COPY_ONLY");
await using var session = await AgentSession.OpenAsync(sql);
var snapshot = await session.CollectAsync<DatabaseSnapshot>(new DatabaseCollector());
var master = Assert.Single(snapshot.Databases, d => d.Name == "master");
Assert.True(master.IsSystemDatabase);
Assert.DoesNotContain(snapshot.Databases, d => d.Name == "tempdb");
var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase);
Assert.Equal("ONLINE", db.State);
Assert.Equal("CHECKSUM", db.PageVerify);
Assert.NotNull(db.LastFullBackup);
Assert.Contains(db.Files, f => f.Type == "ROWS");
Assert.Contains(db.Files, f => f.Type == "LOG");
Assert.True(db.VirtualLogFileCount > 0);
Assert.NotNull(db.QueryStore);
Assert.Equal("READ_WRITE", db.QueryStore.ActualState);
Assert.NotNull(db.LastGoodCheckDb); // 1900-01-01: CHECKDB never ran
}
[Fact]
public async Task Wait_stats_produce_a_delta_on_the_second_collection()
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new WaitStatsCollector();
var first = await session.CollectAsync<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>>(collector);
await sql.ExecuteAsSaAsync("WAITFOR DELAY '00:00:01'; SELECT COUNT(*) FROM sys.objects a CROSS JOIN sys.objects b;");
var second = await session.CollectAsync(collector, first);
Assert.Null(first.Delta);
Assert.NotEmpty(first.Snapshot.Waits);
Assert.NotNull(second.Delta);
Assert.DoesNotContain(second.Delta.Waits, w => WaitCatalog.IsBenign(w.WaitType));
}
[Fact]
public async Task File_io_and_counters_produce_deltas()
{
await using var session = await AgentSession.OpenAsync(sql);
var io1 = await session.CollectAsync<CumulativeSample<FileIoSnapshot, FileIoDelta>>(new FileIoCollector());
var io2 = await session.CollectAsync(new FileIoCollector(), io1);
var pc1 = await session.CollectAsync<CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>>(new PerformanceCounterCollector());
await Task.Delay(1100);
var pc2 = await session.CollectAsync(new PerformanceCounterCollector(), pc1);
Assert.Contains(io1.Snapshot.Files, f => f.DatabaseName == "master" && f.FileType == "LOG");
Assert.NotNull(io2.Delta);
Assert.NotNull(pc1.Snapshot.PageLifeExpectancySeconds);
Assert.NotNull(pc1.Snapshot.MemoryGrantsPending);
Assert.NotNull(pc2.Delta?.BatchRequestsPerSecond);
}
[Fact]
public async Task Cpu_history_comes_from_the_scheduler_monitor()
{
await using var session = await AgentSession.OpenAsync(sql);
var cpu = await session.CollectAsync<CpuUtilization>(new CpuCollector());
// The ring buffer gets its first record about a minute after startup.
Assert.All(cpu.Samples, s => Assert.InRange(s.SqlServerPercent + s.IdlePercent, 0, 100));
Assert.Equal(cpu.Samples.OrderBy(s => s.Time), cpu.Samples);
}
[Fact]
public async Task Blocking_finds_sleeping_head_blocker_with_open_transaction()
{
await using var blocker = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await blocker.OpenAsync();
await using (var begin = blocker.CreateCommand())
{
begin.CommandText = "BEGIN TRAN; UPDATE dbo.CustomerData SET Secret = N'låst' WHERE Id = 1;";
await begin.ExecuteNonQueryAsync();
}
await using var victim = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await victim.OpenAsync();
await using var blockedCommand = victim.CreateCommand();
blockedCommand.CommandText = "SELECT Secret FROM dbo.CustomerData WHERE Id = 1;";
blockedCommand.CommandTimeout = 30;
var blockedQuery = blockedCommand.ExecuteScalarAsync();
await Task.Delay(1500);
await using var session = await AgentSession.OpenAsync(sql);
var snapshot = await session.CollectAsync<BlockingSnapshot>(new BlockingCollector());
await using (var rollback = blocker.CreateCommand())
{
rollback.CommandText = "ROLLBACK;";
await rollback.ExecuteNonQueryAsync();
}
await blockedQuery;
var head = Assert.Single(snapshot.HeadBlockers);
Assert.Equal("sleeping", head.Status);
Assert.Equal(1, head.OpenTransactionCount);
Assert.Contains("UPDATE dbo.CustomerData", head.SqlText, StringComparison.Ordinal);
var blocked = Assert.Single(snapshot.BlockedBy(head.SessionId));
Assert.StartsWith("LCK_M_S", blocked.WaitType, StringComparison.Ordinal);
Assert.Equal(SqlServerFixture.TestDatabase, blocked.DatabaseName);
// Simple statements are auto-parameterized: SELECT [Secret] FROM [dbo].[CustomerData] WHERE [Id]=@1.
Assert.Contains("CustomerData", blocked.SqlText, StringComparison.Ordinal);
}
[Fact]
public async Task Deadlocks_are_read_from_system_health_and_continue_from_last_position()
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new DeadlockCollector();
var before = await session.CollectAsync<DeadlockSnapshot>(collector);
await CauseDeadlockAsync();
var after = await WaitForDeadlockAsync(session, collector, before);
var deadlock = Assert.Single(after.NewDeadlocks);
Assert.Equal(2, deadlock.Processes.Count);
Assert.Single(deadlock.Processes, p => p.IsVictim);
Assert.All(deadlock.Processes, p => Assert.Contains("UPDATE dbo.CustomerData", p.InputBuffer, StringComparison.Ordinal));
Assert.StartsWith("<deadlock", deadlock.GraphXml, StringComparison.Ordinal);
Assert.NotNull(after.LastFileName);
var again = await session.CollectAsync(collector, after);
Assert.Empty(again.NewDeadlocks);
}
private static async Task<DeadlockSnapshot> WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous)
{
// system_health dispatches events to its targets with up to 120 seconds latency.
for (var attempt = 0; attempt < 180; attempt++)
{
var snapshot = await session.CollectAsync(collector, previous);
if (snapshot.NewDeadlocks.Count > 0)
{
return snapshot;
}
await Task.Delay(1000);
}
throw new TimeoutException("The deadlock did not appear in system_health.");
}
private async Task CauseDeadlockAsync()
{
await using var a = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await using var b = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await a.OpenAsync();
await b.OpenAsync();
await ExecuteAsync(a, "BEGIN TRAN; UPDATE dbo.CustomerData SET Secret = N'a' WHERE Id = 1;");
await ExecuteAsync(b, "BEGIN TRAN; UPDATE dbo.CustomerData SET Secret = N'b' WHERE Id = 2;");
var first = ExecuteAsync(a, "UPDATE dbo.CustomerData SET Secret = N'a' WHERE Id = 2;");
await Task.Delay(500);
var second = ExecuteAsync(b, "UPDATE dbo.CustomerData SET Secret = N'b' WHERE Id = 1;");
var results = await Task.WhenAll(Capture(first), Capture(second));
Assert.Single(results, e => e is SqlException { Number: 1205 });
await ExecuteAsync(a, "IF @@TRANCOUNT > 0 ROLLBACK;");
await ExecuteAsync(b, "IF @@TRANCOUNT > 0 ROLLBACK;");
}
private static async Task<Exception?> Capture(Task task)
{
try
{
await task;
return null;
}
catch (SqlException ex)
{
return ex;
}
}
private static async Task ExecuteAsync(SqlConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = 30;
await command.ExecuteNonQueryAsync();
}
}

View file

@ -8,34 +8,9 @@ namespace SQLInsight.IntegrationTests;
/// </summary> /// </summary>
[Collection(SqlServerTestGroup.Name)] [Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")] [Trait("Category", "Integration")]
public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime public class LeastPrivilegeTests(SqlServerFixture sql)
{ {
private const string Login = "sqlinsight_agent"; private const string Database = SqlServerFixture.TestDatabase;
private const string Password = "Agent-Pa55word!";
private const string Database = "SQLInsightQsTest";
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"""
IF SUSER_ID(N'{Login}') IS NULL CREATE LOGIN [{Login}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF;
IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];
""");
await sql.ExecuteAsSaAsync($"""
USE [{Database}];
ALTER DATABASE [{Database}] SET QUERY_STORE = ON;
IF OBJECT_ID(N'dbo.CustomerData') IS NULL
BEGIN
CREATE TABLE dbo.CustomerData (Id int NOT NULL PRIMARY KEY, Secret nvarchar(50) NOT NULL);
INSERT dbo.CustomerData VALUES (1, N'fortroligt');
END;
""");
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
await sql.ExecuteAsSaAsync(script);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact] [Fact]
public async Task Agent_login_is_not_sysadmin() public async Task Agent_login_is_not_sysadmin()
@ -84,21 +59,12 @@ public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime
[Fact] [Fact]
public async Task Script_is_idempotent() public async Task Script_is_idempotent()
{ {
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql")); await sql.ExecuteAsSaAsync(await SqlServerFixture.GrantScriptAsync());
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
await sql.ExecuteAsSaAsync(script);
} }
private async Task<T> ScalarAsync<T>(string query) private async Task<T> ScalarAsync<T>(string query)
{ {
var builder = new SqlConnectionStringBuilder(sql.SaConnectionString) await using var connection = new SqlConnection(sql.AgentConnectionString);
{
UserID = Login,
Password = Password,
InitialCatalog = "master",
};
await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync(); await connection.OpenAsync();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = query; command.CommandText = query;

View file

@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" /> <None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />

View file

@ -4,12 +4,15 @@ using Testcontainers.MsSql;
namespace SQLInsight.IntegrationTests; namespace SQLInsight.IntegrationTests;
/// <summary> /// <summary>
/// One SQL Server container for the test run. The image is chosen with <c>SQLINSIGHT_MSSQL_IMAGE</c> so CI can run /// One SQL Server container for the test run, with the agent login set up by the real least-privilege script.
/// the same tests against 2017, 2019, 2022 and 2025. /// The image is chosen with <c>SQLINSIGHT_MSSQL_IMAGE</c> so CI can run the same tests against 2017–2025.
/// </summary> /// </summary>
public sealed class SqlServerFixture : IAsyncLifetime public sealed class SqlServerFixture : IAsyncLifetime
{ {
public const string DefaultImage = "mcr.microsoft.com/mssql/server:2022-latest"; public const string DefaultImage = "mcr.microsoft.com/mssql/server:2022-latest";
public const string AgentLogin = "sqlinsight_agent";
public const string AgentPassword = "Agent-Pa55word!";
public const string TestDatabase = "SQLInsightQsTest";
public static string Image => Environment.GetEnvironmentVariable("SQLINSIGHT_MSSQL_IMAGE") ?? DefaultImage; public static string Image => Environment.GetEnvironmentVariable("SQLINSIGHT_MSSQL_IMAGE") ?? DefaultImage;
@ -17,16 +20,42 @@ public sealed class SqlServerFixture : IAsyncLifetime
public string SaConnectionString => _container.GetConnectionString(); public string SaConnectionString => _container.GetConnectionString();
public string Server public string Server => new SqlConnectionStringBuilder(SaConnectionString).DataSource;
public string SaPassword => new SqlConnectionStringBuilder(SaConnectionString).Password;
public string AgentConnectionString => new SqlConnectionStringBuilder(SaConnectionString)
{ {
get UserID = AgentLogin,
{ Password = AgentPassword,
var builder = new SqlConnectionStringBuilder(SaConnectionString); InitialCatalog = "master",
return builder.DataSource; }.ConnectionString;
}
public string DatabaseConnectionString(string database) =>
new SqlConnectionStringBuilder(SaConnectionString) { InitialCatalog = database }.ConnectionString;
public async Task InitializeAsync()
{
await _container.StartAsync();
await ExecuteAsSaAsync($"""
CREATE LOGIN [{AgentLogin}] WITH PASSWORD = N'{AgentPassword}', CHECK_POLICY = OFF;
CREATE DATABASE [{TestDatabase}];
""");
await ExecuteAsSaAsync($"""
USE [{TestDatabase}];
ALTER DATABASE [{TestDatabase}] SET QUERY_STORE = ON;
CREATE TABLE dbo.CustomerData (Id int NOT NULL PRIMARY KEY, Secret nvarchar(50) NOT NULL);
INSERT dbo.CustomerData VALUES (1, N'fortroligt'), (2, N'hemmeligt');
""");
await ExecuteAsSaAsync(await GrantScriptAsync());
} }
public Task InitializeAsync() => _container.StartAsync(); public static async Task<string> GrantScriptAsync()
{
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
return script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{AgentLogin}'", StringComparison.Ordinal);
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask(); public Task DisposeAsync() => _container.DisposeAsync().AsTask();

View file

@ -0,0 +1,160 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Configuration;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class ConfigurationRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
[Fact]
public void Default_max_server_memory_is_high_with_recommendation()
{
var result = Run(new MaxServerMemoryDefaultRule(), With(Config(("max server memory (MB)", 2147483647)), Server(memoryMb: 65536)));
var finding = Assert.Single(result.Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Contains("58.983", finding.Recommendation, StringComparison.Ordinal); // 65536 - 6553 (10 %)
Assert.Contains("58983", finding.FixScript, StringComparison.Ordinal);
}
[Fact]
public void Default_max_server_memory_without_server_data_still_reported()
{
var result = Run(new MaxServerMemoryDefaultRule(), With(Config(("max server memory (MB)", 2147483647))));
Assert.Equal(RuleOutcome.Findings, result.Outcome);
Assert.Null(Assert.Single(result.Findings).FixScript);
}
[Theory]
[InlineData(16384, 4096, 12288)] // small server: keep 4 GB
[InlineData(262144, 26214, 235930)] // large server: keep 10 %
public void Memory_guidance_keeps_the_larger_of_4gb_and_10_percent(long physical, long reserve, long recommended)
{
Assert.Equal(reserve, MemoryGuidance.Reserve(physical));
Assert.Equal(recommended, MemoryGuidance.Recommended(physical));
}
[Theory]
[InlineData(64000, RuleOutcome.Findings)]
[InlineData(58000, RuleOutcome.Passed)]
[InlineData(2147483647, RuleOutcome.Passed)] // reported by CFG-001 instead
public void Max_server_memory_too_high(long value, RuleOutcome expected)
{
Assert.Equal(expected, Run(new MaxServerMemoryTooHighRule(), With(Config(("max server memory (MB)", value)), Server(memoryMb: 65536))).Outcome);
}
[Theory]
[InlineData(5, 0, 8, RuleOutcome.Findings)]
[InlineData(50, 0, 8, RuleOutcome.Passed)]
[InlineData(5, 1, 8, RuleOutcome.Passed)] // MAXDOP 1: no parallelism at all
[InlineData(5, 0, 1, RuleOutcome.Passed)] // single CPU
public void Cost_threshold(long ctfp, long maxdop, int cpus, RuleOutcome expected)
{
var context = With(Config(("cost threshold for parallelism", ctfp), ("max degree of parallelism", maxdop)), Server(cpus));
Assert.Equal(expected, Run(new CostThresholdRule(), context).Outcome);
}
[Theory]
[InlineData(0, 8, 1, RuleOutcome.Passed, null)] // one node, ≤ 8: 0 is within guidance
[InlineData(0, 16, 1, RuleOutcome.Findings, 8)] // one node, > 8: 8
[InlineData(12, 16, 1, RuleOutcome.Findings, 8)]
[InlineData(0, 16, 2, RuleOutcome.Findings, 8)] // several nodes, ≤ 16 per node: per node
[InlineData(0, 12, 2, RuleOutcome.Findings, 6)]
[InlineData(16, 32, 2, RuleOutcome.Passed, null)] // 16 per node: up to 16 is fine
[InlineData(24, 64, 2, RuleOutcome.Findings, 16)] // > 16 per node: half, at most 16
[InlineData(16, 64, 2, RuleOutcome.Passed, null)]
public void MaxDop_follows_numa_guidance(long maxdop, int cpus, int numa, RuleOutcome expected, int? recommended)
{
var result = Run(new MaxDopRule(), With(Config(("max degree of parallelism", maxdop)), Server(cpus, numa: numa)));
Assert.Equal(expected, result.Outcome);
if (recommended is { } r)
{
Assert.Contains($"'max degree of parallelism', {r};", Assert.Single(result.Findings).FixScript, StringComparison.Ordinal);
}
}
[Fact]
public void Ad_hoc_bloat_needs_share_and_size()
{
var bloated = new PlanCacheSnapshot { CapturedAt = Now, TotalPlans = 10000, TotalMb = 2000, SingleUseAdhocPlans = 8000, SingleUseAdhocMb = 1200 };
var small = bloated with { TotalMb = 200, SingleUseAdhocMb = 80 };
var off = Config(("optimize for ad hoc workloads", 0));
Assert.Equal(RuleOutcome.Findings, Run(new OptimizeForAdHocRule(), With(off, bloated)).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new OptimizeForAdHocRule(), With(off, small)).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new OptimizeForAdHocRule(), With(Config(("optimize for ad hoc workloads", 1)), bloated)).Outcome);
}
[Fact]
public void Missing_option_on_old_version_is_not_measurable()
{
var result = Run(new OptimizeForAdHocRule(), WithVersion("9.00.5000.00", Config(), new PlanCacheSnapshot { CapturedAt = Now, TotalPlans = 1, TotalMb = 1, SingleUseAdhocPlans = 0, SingleUseAdhocMb = 0 }));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("SQL Server 2005", result.Reason, StringComparison.Ordinal);
}
[Theory]
[InlineData("SQLI-CFG-006", "priority boost", 1, Severity.High)]
[InlineData("SQLI-CFG-007", "lightweight pooling", 1, Severity.High)]
[InlineData("SQLI-CFG-008", "xp_cmdshell", 1, Severity.Medium)]
[InlineData("SQLI-CFG-009", "backup compression default", 0, Severity.Low)]
[InlineData("SQLI-CFG-010", "remote admin connections", 0, Severity.Low)]
public void Simple_option_rules(string id, string option, long badValue, Severity severity)
{
var rule = SimpleConfigurationRules.Create().Single(r => r.Id == id);
var bad = Run(rule, With(Config((option, badValue))));
var good = Run(rule, With(Config((option, 1 - badValue))));
Assert.Equal(severity, Assert.Single(bad.Findings).Severity);
Assert.Equal(RuleOutcome.Passed, good.Outcome);
}
[Fact]
public void Backup_compression_is_not_reported_on_express()
{
var rule = SimpleConfigurationRules.Create().Single(r => r.Id == "SQLI-CFG-009");
var context = Context("16.0.4135.4", edition: Core.Instances.EngineEdition.Express);
context.Data.Set(Config(("backup compression default", 0)));
Assert.Equal(RuleOutcome.Passed, Run(rule, context).Outcome);
}
[Fact]
public void Pending_configuration_ignores_min_server_memory()
{
var config = new ConfigurationSnapshot
{
CapturedAt = Now,
Options =
[
new ConfigurationOption("min server memory (MB)", 0, 16),
new ConfigurationOption("max degree of parallelism", 8, 0),
],
};
var finding = Assert.Single(Run(new PendingConfigurationRule(), With(config)).Findings);
var evidence = Assert.Single(finding.Evidence);
Assert.Equal("max degree of parallelism", evidence.Name);
}
[Theory]
[InlineData("9.00.5000.00", new int[0], RuleOutcome.Findings)]
[InlineData("9.00.5000.00", new[] { 1222 }, RuleOutcome.Passed)]
[InlineData("8.00.2039", new[] { 1204 }, RuleOutcome.Passed)]
[InlineData("16.0.4135.4", new int[0], RuleOutcome.Passed)] // system_health covers it
public void Deadlock_trace_flag_only_matters_without_system_health(string version, int[] flags, RuleOutcome expected)
{
var context = WithVersion(version, new TraceFlagSnapshot(Now, flags));
Assert.Equal(expected, Run(new DeadlockTraceFlagRule(), context).Outcome);
}
}

View file

@ -0,0 +1,272 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Databases;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class DatabaseRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static RuleResult Run(IRule rule, params DatabaseInfo[] databases) => Run(rule, With(DatabaseList(databases)));
[Fact]
public void Healthy_database_passes_every_database_rule()
{
var rules = new IRule[]
{
new DatabaseStateRule(), new CompatibilityLevelRule(), new FullBackupRule(), new LogBackupRule(), new CheckDbRule(),
new VirtualLogFileRule(), new FileGrowthRule(), new FileMaxSizeRule(), new QueryStoreStateRule(),
}.Concat(SimpleDatabaseRules.Create());
foreach (var rule in rules)
{
Assert.Equal(RuleOutcome.Passed, Run(rule, Database()).Outcome);
}
}
[Theory]
[InlineData("SUSPECT", Severity.Critical)]
[InlineData("RECOVERY_PENDING", Severity.Critical)]
[InlineData("OFFLINE", Severity.Low)]
public void Unusable_database_state(string state, Severity severity)
{
var finding = Assert.Single(Run(new DatabaseStateRule(), Database(change: d => d with { State = state })).Findings);
Assert.Equal(severity, finding.Severity);
Assert.Equal("database: Sales", finding.Subject);
}
[Fact]
public void Settings_rules_skip_offline_databases()
{
var offline = Database(change: d => d with { State = "OFFLINE", IsAutoShrink = true });
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-002"), offline).Outcome);
}
[Theory]
[InlineData("SQLI-DB-002")]
[InlineData("SQLI-DB-003")]
[InlineData("SQLI-DB-004")]
[InlineData("SQLI-DB-005")]
public void Simple_database_rules_find_and_script_the_fix(string id)
{
var bad = Database("My]Db", d => d with { IsAutoShrink = true, IsAutoClose = true, PageVerify = "TORN_PAGE_DETECTION", IsTrustworthy = true });
var finding = Assert.Single(Run(SimpleDatabaseRules.Create().Single(r => r.Id == id), bad).Findings);
Assert.StartsWith("ALTER DATABASE [My]]Db]", finding.FixScript, StringComparison.Ordinal);
}
[Fact]
public void Trustworthy_msdb_is_by_design()
{
var msdb = Database("msdb", d => d with { DatabaseId = 4, IsTrustworthy = true });
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-005"), msdb).Outcome);
}
[Fact]
public void Old_compatibility_level_is_reported_for_user_databases_only()
{
var user = Database(change: d => d with { CompatibilityLevel = 130 });
var system = Database("master", d => d with { DatabaseId = 1, CompatibilityLevel = 130 });
Assert.Single(Run(new CompatibilityLevelRule(), user, system).Findings);
}
[Fact]
public void Never_backed_up_is_critical()
{
var finding = Assert.Single(Run(new FullBackupRule(), Database(change: d => d with { LastFullBackup = null })).Findings);
Assert.Equal(Severity.Critical, finding.Severity);
}
[Fact]
public void Old_backup_is_high_but_medium_in_an_availability_group()
{
var old = Database(change: d => d with { LastFullBackup = Now.UtcDateTime.AddDays(-9) });
Assert.Equal(Severity.High, Assert.Single(Run(new FullBackupRule(), old).Findings).Severity);
Assert.Equal(Severity.Medium, Assert.Single(Run(new FullBackupRule(), old with { IsInAvailabilityGroup = true }).Findings).Severity);
}
[Fact]
public void Backup_rule_skips_new_databases_model_and_secondaries()
{
var created = Database(change: d => d with { LastFullBackup = null, CreateDate = Now.UtcDateTime.AddHours(-2) });
var model = Database("model", d => d with { DatabaseId = 3, LastFullBackup = null });
var secondary = Database(change: d => d with { LastFullBackup = null, IsReplicaOrMirror = true });
Assert.Equal(RuleOutcome.Passed, Run(new FullBackupRule(), created, model, secondary).Outcome);
}
[Theory]
[InlineData("FULL", 30, RuleOutcome.Findings)]
[InlineData("FULL", 12, RuleOutcome.Passed)]
[InlineData("BULK_LOGGED", 30, RuleOutcome.Findings)]
[InlineData("SIMPLE", 30, RuleOutcome.Passed)]
public void Log_backups_in_full_recovery(string model, int hoursSinceLogBackup, RuleOutcome expected)
{
var db = Database(change: d => d with { RecoveryModel = model, LastLogBackup = Now.UtcDateTime.AddHours(-hoursSinceLogBackup) });
Assert.Equal(expected, Run(new LogBackupRule(), db).Outcome);
}
[Fact]
public void Full_recovery_without_any_full_backup_is_not_a_log_backup_problem()
{
var db = Database(change: d => d with { LastFullBackup = null, LastLogBackup = null });
Assert.Equal(RuleOutcome.Passed, Run(new LogBackupRule(), db).Outcome);
}
[Fact]
public void CheckDb_never_run_is_high()
{
var db = Database(change: d => d with { LastGoodCheckDb = new DateTime(1900, 1, 1) });
var finding = Assert.Single(Run(new CheckDbRule(), db).Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Contains("aldrig", finding.Title, StringComparison.Ordinal);
}
[Theory]
[InlineData(10, Severity.Medium)]
[InlineData(40, Severity.High)]
public void CheckDb_age(int days, Severity severity)
{
var db = Database(change: d => d with { LastGoodCheckDb = Now.UtcDateTime.AddDays(-days) });
Assert.Equal(severity, Assert.Single(Run(new CheckDbRule(), db).Findings).Severity);
}
[Fact]
public void CheckDb_is_not_measurable_before_2016_sp2()
{
var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database())));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
}
[Fact]
public void Suspect_pages_ignore_repaired_pages()
{
var pages = new SuspectPageSnapshot(Now,
[
new SuspectPage("Sales", 1, 1234, 2, 1, Now.UtcDateTime),
new SuspectPage("Sales", 1, 5678, 3, 2, Now.UtcDateTime),
new SuspectPage("Hr", 1, 42, 5, 1, Now.UtcDateTime), // repaired
]);
var finding = Assert.Single(Run(new SuspectPagesRule(), With(pages)).Findings);
Assert.Equal(Severity.Critical, finding.Severity);
Assert.Equal("database: Sales", finding.Subject);
Assert.Equal(2, finding.Evidence.Count);
}
[Theory]
[InlineData(999, RuleOutcome.Passed)]
[InlineData(1500, RuleOutcome.Findings)]
public void Vlf_threshold(int vlfs, RuleOutcome expected)
{
Assert.Equal(expected, Run(new VirtualLogFileRule(), Database(change: d => d with { VirtualLogFileCount = vlfs })).Outcome);
}
[Fact]
public void File_growth_flags_percent_and_tiny_fixed_growth_on_large_files()
{
static DatabaseFileInfo File(string name, long size, long growth, bool percent) => new()
{
LogicalName = name, Type = "ROWS", PhysicalName = name + ".mdf", SizeMb = size, Growth = growth, IsPercentGrowth = percent, MaxSizeMb = -1,
};
var db = Database(change: d => d with
{
Files =
[
File("percent", 50_000, 10, true),
File("tiny", 20_000, 1, false),
File("fine", 20_000, 512, false),
File("small-db", 100, 10, true), // too small to matter
File("fixed-size", 20_000, 0, false), // autogrowth off: deliberate
],
});
var findings = Run(new FileGrowthRule(), db).Findings;
Assert.Equal(["file: Sales/percent", "file: Sales/tiny"], findings.Select(f => f.Subject));
}
[Theory]
[InlineData(950, 1000, Severity.High)]
[InlineData(1000, 1000, Severity.Critical)]
public void File_near_max_size(long size, long max, Severity severity)
{
var db = Database(change: d => d with
{
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = max }],
});
Assert.Equal(severity, Assert.Single(Run(new FileMaxSizeRule(), db).Findings).Severity);
}
[Fact]
public void Default_2tb_log_max_size_is_unlimited()
{
var db = Database(change: d => d with
{
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = 2_000_000, Growth = 64, IsPercentGrowth = false, MaxSizeMb = 2_097_152 }],
});
Assert.Equal(RuleOutcome.Passed, Run(new FileMaxSizeRule(), db).Outcome);
}
[Fact]
public void Query_store_that_switched_to_read_only_because_full()
{
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_WRITE", "READ_ONLY", 65536, 1000, 1000) });
var finding = Assert.Single(Run(new QueryStoreStateRule(), db).Findings);
Assert.Contains("fuld", finding.Title, StringComparison.Ordinal);
Assert.Contains("MAX_STORAGE_SIZE_MB", finding.Recommendation, StringComparison.Ordinal);
}
[Fact]
public void Query_store_deliberately_read_only_passes()
{
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_ONLY", "READ_ONLY", 0, 10, 1000) });
Assert.Equal(RuleOutcome.Passed, Run(new QueryStoreStateRule(), db).Outcome);
}
[Fact]
public void Tempdb_with_one_file_on_many_cpus()
{
var snapshot = DatabaseList() with
{
TempdbFiles = [new DatabaseFileInfo { LogicalName = "tempdev", Type = "ROWS", PhysicalName = "t", SizeMb = 8, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 }],
};
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 16))).Findings);
Assert.Contains("Brug 8", finding.Recommendation, StringComparison.Ordinal);
}
[Fact]
public void Tempdb_files_of_different_size()
{
static DatabaseFileInfo File(string name, long size) => new() { LogicalName = name, Type = "ROWS", PhysicalName = name, SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 };
var snapshot = DatabaseList() with { TempdbFiles = [File("a", 1024), File("b", 1024), File("c", 1024), File("d", 4096)] };
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 4))).Findings);
Assert.Equal(Severity.Low, finding.Severity);
}
}

View file

@ -0,0 +1,197 @@
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) => new()
{
CapturedAt = Now,
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 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);
}
}

View file

@ -69,6 +69,23 @@ public class RuleEngineTests
Assert.Same(finding, Assert.Single(results[1].Findings)); Assert.Same(finding, Assert.Single(results[1].Findings));
} }
[Fact]
public void Rule_needing_uncollected_data_is_not_measurable_with_the_collectors_reason()
{
var rule = new StubRule("W", [], ctx =>
{
ctx.Data.Get<SQLInsight.Core.Data.WaitStatsSnapshot>();
return RuleEvaluation.Passed();
});
var context = TestInstances.Context("16.0.4135.4");
context.Data.MarkUnavailable<SQLInsight.Core.Data.WaitStatsSnapshot>("Adgang nægtet til sys.dm_os_wait_stats.");
var result = Assert.Single(new RuleEngine([rule]).Evaluate(context));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Equal("Adgang nægtet til sys.dm_os_wait_stats.", result.Reason);
}
[Fact] [Fact]
public void Duplicate_rule_ids_are_rejected() public void Duplicate_rule_ids_are_rejected()
{ {

View file

@ -1,3 +1,4 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances; using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine; using SQLInsight.Rules.Engine;
@ -5,7 +6,9 @@ namespace SQLInsight.Rules.Tests;
internal static class TestInstances internal static class TestInstances
{ {
public static RuleContext Context(string version, DateTimeOffset? now = null) public static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
public static RuleContext Context(string version, DateTimeOffset? now = null, EngineEdition edition = EngineEdition.Standard)
{ {
var info = new InstanceInfo var info = new InstanceInfo
{ {
@ -13,8 +16,85 @@ internal static class TestInstances
Version = SqlServerVersion.Parse(version), Version = SqlServerVersion.Parse(version),
ProductLevel = "RTM", ProductLevel = "RTM",
Edition = "Standard Edition", Edition = "Standard Edition",
EngineEdition = EngineEdition.Standard, EngineEdition = edition,
}; };
return new RuleContext(info, InstanceCapabilities.From(info), now ?? new DateTimeOffset(2026, 9, 29, 0, 0, 0, TimeSpan.Zero)); return new RuleContext(info, InstanceCapabilities.From(info), now ?? Now);
} }
/// <summary>SQL Server 2022 with the given data collected.</summary>
public static RuleContext With(params object[] data) => WithVersion("16.0.4135.4", data);
public static RuleContext WithVersion(string version, params object[] data)
{
var context = Context(version);
foreach (var item in data)
{
context.Data.Set(item.GetType(), item);
}
return context;
}
public static ConfigurationSnapshot Config(params (string Name, long Value)[] options) => new()
{
CapturedAt = Now,
Options = options.Select(o => new ConfigurationOption(o.Name, o.Value, o.Value)).ToList(),
};
public static ServerSnapshot Server(int cpus = 8, long memoryMb = 65536, int numa = 1) => new()
{
CapturedAt = Now,
SqlServerStartTime = Now.UtcDateTime.AddDays(-10),
LogicalCpuCount = cpus,
VisibleOnlineSchedulers = cpus,
NumaNodeCount = numa,
PhysicalMemoryMb = memoryMb,
};
public static DatabaseInfo Database(string name = "Sales", Func<DatabaseInfo, DatabaseInfo>? change = null)
{
var db = new DatabaseInfo
{
Name = name,
DatabaseId = 5,
State = "ONLINE",
RecoveryModel = "FULL",
CompatibilityLevel = 160,
PageVerify = "CHECKSUM",
CreateDate = Now.UtcDateTime.AddYears(-1),
LastFullBackup = Now.UtcDateTime.AddDays(-1),
LastLogBackup = Now.UtcDateTime.AddMinutes(-15),
LastGoodCheckDb = Now.UtcDateTime.AddDays(-2),
VirtualLogFileCount = 50,
};
return change is null ? db : change(db);
}
public static DatabaseSnapshot DatabaseList(params DatabaseInfo[] databases) => new() { CapturedAt = Now, Databases = databases };
public static CumulativeSample<WaitStatsSnapshot, WaitStatsDelta> Waits(TimeSpan interval, params WaitStat[] waits)
{
var snapshot = new WaitStatsSnapshot { CapturedAt = Now, SqlServerStartTime = Now.UtcDateTime.AddDays(-1), Waits = waits };
return new(snapshot, new WaitStatsDelta { From = Now - interval, To = Now, Waits = waits });
}
public static CumulativeSample<FileIoSnapshot, FileIoDelta> Io(params FileIoStat[] files)
{
var snapshot = new FileIoSnapshot { CapturedAt = Now, SqlServerStartTime = Now.UtcDateTime.AddDays(-1), Files = files };
return new(snapshot, new FileIoDelta { From = Now.AddMinutes(-5), To = Now, Files = files });
}
public static FileIoStat File(string type, long reads = 0, long readStallMs = 0, long writes = 0, long writeStallMs = 0) => new()
{
DatabaseName = "Sales",
FileId = type == "LOG" ? 2 : 1,
FileType = type,
PhysicalName = type == "LOG" ? "L:\\Sales_log.ldf" : "D:\\Sales.mdf",
Reads = reads,
BytesRead = reads * 8192,
ReadStallMs = readStallMs,
Writes = writes,
BytesWritten = writes * 8192,
WriteStallMs = writeStallMs,
};
} }