diff --git a/Directory.Build.props b/Directory.Build.props index 9a51e2f..e1fec25 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,8 +8,8 @@ latest-recommended true true - - $(NoWarn);CS1591 + + $(NoWarn);CS1591;CS1573 SEOSoft SQLInsight Pro Copyright (c) SEOSoft diff --git a/SQLInsight.slnx b/SQLInsight.slnx index c8a68d7..28b3969 100644 --- a/SQLInsight.slnx +++ b/SQLInsight.slnx @@ -1,6 +1,7 @@ + @@ -9,6 +10,7 @@ + diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs new file mode 100644 index 0000000..d4f7421 --- /dev/null +++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs @@ -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 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(), + ]; +} diff --git a/src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs b/src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs new file mode 100644 index 0000000..8ffcb40 --- /dev/null +++ b/src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs @@ -0,0 +1,79 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Concurrency; + +/// +/// Blocked sessions and their blockers right now. Head blockers are often sleeping sessions with an open +/// transaction, which have no row in sys.dm_exec_requests, so sessions are the starting point. +/// +public sealed class BlockingCollector : Collector +{ + public override string Name => "blocking"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1); + + protected override async Task 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); +} diff --git a/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs b/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs new file mode 100644 index 0000000..dbbf2aa --- /dev/null +++ b/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs @@ -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; + +/// +/// Reads new deadlock graphs from the built-in system_health 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. +/// +public sealed class DeadlockCollector : Collector +{ + /// On the first run, only deadlocks this recent are reported. + public static readonly TimeSpan InitialLookback = TimeSpan.FromDays(1); + + public override string Name => "deadlocks"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5); + public override IReadOnlyCollection RequiredCapabilities => [Capability.SystemHealthSession]; + + protected override async Task 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 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? 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() + .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 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(); + if (rows.FirstOrDefault() is { } xml) + { + deadlocks = XElement.Parse(xml).Elements("event") + .Where(e => (string?)e.Attribute("name") == "xml_deadlock_report") + .Select(DeadlockParser.ParseEvent) + .OfType() + .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, + }; + } +} + +/// +/// Turns an xml_deadlock_report event into a . +/// +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; + } + } +} diff --git a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs new file mode 100644 index 0000000..3bfedcb --- /dev/null +++ b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs @@ -0,0 +1,223 @@ +using System.Data.Common; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Databases; + +/// +/// Settings, backup history, CHECKDB status, VLFs, Query Store state and files per database. +/// +public sealed class DatabaseCollector : Collector +{ + // 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 CollectAsync(CollectorContext context, DatabaseSnapshot? previous) + { + var connection = context.Connection; + var ct = context.CancellationToken; + var version = context.Instance.Version; + + List 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(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 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; + } + } +} diff --git a/src/SQLInsight.Collectors/Db.cs b/src/SQLInsight.Collectors/Db.cs new file mode 100644 index 0000000..e558e6f --- /dev/null +++ b/src/SQLInsight.Collectors/Db.cs @@ -0,0 +1,89 @@ +using System.Data.Common; +using System.Globalization; + +namespace SQLInsight.Collectors; + +/// +/// Minimal, provider-neutral query helpers. Works the same over SqlClient and ODBC, so collectors never use +/// named parameters (ODBC only knows positional ?); the few literals needed are escaped with . +/// +internal static class Db +{ + public const int CommandTimeoutSeconds = 30; + + public static async Task> QueryAsync(DbConnection connection, string sql, Func 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(); + var row = new Row(reader); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + rows.Add(map(row)); + } + + return rows; + } + + public static async Task QuerySingleAsync(DbConnection connection, string sql, Func map, CancellationToken cancellationToken) + where T : class + { + var rows = await QueryAsync(connection, sql, map, cancellationToken).ConfigureAwait(false); + return rows.Count > 0 ? rows[0] : null; + } + + /// An N'…' string literal with quotes escaped. + public static string Literal(string value) => "N'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + + /// A [bracketed] identifier with brackets escaped. + public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; + + /// + /// The SQL expression for when the instance started. Cumulative DMVs reset at that moment. + /// + 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 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]; + } +} + +/// +/// 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. +/// +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; +} diff --git a/src/SQLInsight.Collectors/ICollector.cs b/src/SQLInsight.Collectors/ICollector.cs new file mode 100644 index 0000000..001b2e9 --- /dev/null +++ b/src/SQLInsight.Collectors/ICollector.cs @@ -0,0 +1,49 @@ +using System.Data.Common; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors; + +/// +/// 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 . +/// +public interface ICollector +{ + string Name { get; } + + /// The type of the collected data, as rules look it up in CollectedData. + Type DataType { get; } + + TimeSpan DefaultInterval { get; } + + /// What the instance must support. If anything is missing, the collector does not run. + IReadOnlyCollection RequiredCapabilities { get; } + + Task CollectAsync(CollectorContext context); +} + +/// This collector's result from the previous run on the same instance, if any. +public sealed record CollectorContext( + DbConnection Connection, + InstanceInfo Instance, + InstanceCapabilities Capabilities, + DateTimeOffset Now, + object? Previous, + CancellationToken CancellationToken); + +/// +/// Typed base class for collectors. +/// +public abstract class Collector : ICollector + where T : class +{ + public abstract string Name { get; } + public Type DataType => typeof(T); + public abstract TimeSpan DefaultInterval { get; } + public virtual IReadOnlyCollection RequiredCapabilities => []; + + async Task ICollector.CollectAsync(CollectorContext context) => + await CollectAsync(context, context.Previous as T).ConfigureAwait(false); + + protected abstract Task CollectAsync(CollectorContext context, T? previous); +} diff --git a/src/SQLInsight.Collectors/Instance/ConfigurationCollector.cs b/src/SQLInsight.Collectors/Instance/ConfigurationCollector.cs new file mode 100644 index 0000000..a1e7fa8 --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/ConfigurationCollector.cs @@ -0,0 +1,29 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Collectors.Instance; + +public sealed class ConfigurationCollector : Collector +{ + 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 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 }; + } +} diff --git a/src/SQLInsight.Collectors/Instance/PlanCacheCollector.cs b/src/SQLInsight.Collectors/Instance/PlanCacheCollector.cs new file mode 100644 index 0000000..91c7c96 --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/PlanCacheCollector.cs @@ -0,0 +1,33 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Instance; + +public sealed class PlanCacheCollector : Collector +{ + 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 RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task 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!; + } +} diff --git a/src/SQLInsight.Collectors/Instance/ServerCollector.cs b/src/SQLInsight.Collectors/Instance/ServerCollector.cs new file mode 100644 index 0000000..21ec809 --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/ServerCollector.cs @@ -0,0 +1,65 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Collectors.Instance; + +public sealed class ServerCollector : Collector +{ + public override string Name => "server"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15); + + protected override async Task 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 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"), + }; + } +} diff --git a/src/SQLInsight.Collectors/Instance/SuspectPageCollector.cs b/src/SQLInsight.Collectors/Instance/SuspectPageCollector.cs new file mode 100644 index 0000000..4c40443 --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/SuspectPageCollector.cs @@ -0,0 +1,28 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Instance; + +public sealed class SuspectPageCollector : Collector +{ + 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 RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task 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); + } +} diff --git a/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs b/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs new file mode 100644 index 0000000..effb528 --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs @@ -0,0 +1,21 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Collectors.Instance; + +public sealed class TraceFlagCollector : Collector +{ + public override string Name => "trace-flags"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15); + + protected override async Task 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()); + } +} diff --git a/src/SQLInsight.Collectors/Performance/CpuCollector.cs b/src/SQLInsight.Collectors/Performance/CpuCollector.cs new file mode 100644 index 0000000..9a235af --- /dev/null +++ b/src/SQLInsight.Collectors/Performance/CpuCollector.cs @@ -0,0 +1,42 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Performance; + +/// +/// 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. +/// +public sealed class CpuCollector : Collector +{ + 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'%%' + ) rb + ORDER BY rb.[timestamp] DESC + """; + + public override string Name => "cpu"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5); + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task 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 }; + } +} diff --git a/src/SQLInsight.Collectors/Performance/FileIoCollector.cs b/src/SQLInsight.Collectors/Performance/FileIoCollector.cs new file mode 100644 index 0000000..83a52f0 --- /dev/null +++ b/src/SQLInsight.Collectors/Performance/FileIoCollector.cs @@ -0,0 +1,43 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Performance; + +public sealed class FileIoCollector : Collector> +{ + 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 RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task> CollectAsync( + CollectorContext context, CumulativeSample? 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)); + } +} diff --git a/src/SQLInsight.Collectors/Performance/PerformanceCounterCollector.cs b/src/SQLInsight.Collectors/Performance/PerformanceCounterCollector.cs new file mode 100644 index 0000000..88e1abb --- /dev/null +++ b/src/SQLInsight.Collectors/Performance/PerformanceCounterCollector.cs @@ -0,0 +1,47 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Collectors.Performance; + +public sealed class PerformanceCounterCollector : Collector> +{ + // 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> CollectAsync( + CollectorContext context, CumulativeSample? 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)); + } +} diff --git a/src/SQLInsight.Collectors/Performance/WaitStatsCollector.cs b/src/SQLInsight.Collectors/Performance/WaitStatsCollector.cs new file mode 100644 index 0000000..09bb2e7 --- /dev/null +++ b/src/SQLInsight.Collectors/Performance/WaitStatsCollector.cs @@ -0,0 +1,34 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Collectors.Performance; + +public sealed class WaitStatsCollector : Collector> +{ + 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> CollectAsync( + CollectorContext context, CumulativeSample? 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)); + } +} diff --git a/src/SQLInsight.Collectors/SQLInsight.Collectors.csproj b/src/SQLInsight.Collectors/SQLInsight.Collectors.csproj new file mode 100644 index 0000000..0b449a0 --- /dev/null +++ b/src/SQLInsight.Collectors/SQLInsight.Collectors.csproj @@ -0,0 +1,12 @@ + + + SQLInsight.Collectors + + + + + + + + + diff --git a/src/SQLInsight.Core/Collection/CollectedData.cs b/src/SQLInsight.Core/Collection/CollectedData.cs new file mode 100644 index 0000000..50f3096 --- /dev/null +++ b/src/SQLInsight.Core/Collection/CollectedData.cs @@ -0,0 +1,87 @@ +namespace SQLInsight.Core.Collection; + +/// +/// 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. +/// +public sealed class CollectedData +{ + private readonly Dictionary _values = []; + private readonly Dictionary _unavailable = []; + + public void Set(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(string reason) => MarkUnavailable(typeof(T), reason); + + public bool TryGet(out T value) + where T : class + { + if (_values.TryGetValue(typeof(T), out var stored)) + { + value = (T)stored; + return true; + } + + value = null!; + return false; + } + + /// + /// Returns the data, or throws which the rule engine reports as + /// not measurable. + /// + public T Get() + where T : class + { + if (TryGet(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}."); + } +} + +/// +/// Thrown when a rule needs data that has not been, or could not be, collected. +/// +public sealed class DataUnavailableException : Exception +{ + public DataUnavailableException() + { + } + + public DataUnavailableException(string message) + : base(message) + { + } + + public DataUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/SQLInsight.Core/Data/Blocking.cs b/src/SQLInsight.Core/Data/Blocking.cs new file mode 100644 index 0000000..0245383 --- /dev/null +++ b/src/SQLInsight.Core/Data/Blocking.cs @@ -0,0 +1,48 @@ +namespace SQLInsight.Core.Data; + +/// +/// Sessions that were blocked, and the sessions blocking them, at the moment of collection. +/// +public sealed record BlockingSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required IReadOnlyList Sessions { get; init; } + + /// Sessions that block others without being blocked themselves. + public IEnumerable 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 BlockedBy(int sessionId) => Sessions.Where(s => s.BlockingSessionId == sessionId); +} + +public sealed record SessionInfo +{ + public required int SessionId { get; init; } + + /// The session this one is waiting on, or . + public int? BlockingSessionId { get; init; } + + /// running, runnable, suspended, sleeping … + 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; } + + /// The current or last statement, truncated. Never includes parameter values. + public string? SqlText { get; init; } + + public DateTime? LastRequestEndTime { get; init; } +} diff --git a/src/SQLInsight.Core/Data/ConfigurationSnapshot.cs b/src/SQLInsight.Core/Data/ConfigurationSnapshot.cs new file mode 100644 index 0000000..da13e40 --- /dev/null +++ b/src/SQLInsight.Core/Data/ConfigurationSnapshot.cs @@ -0,0 +1,18 @@ +namespace SQLInsight.Core.Data; + +/// +/// Server configuration options (sys.configurations / sp_configure). +/// +public sealed record ConfigurationSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required IReadOnlyList Options { get; init; } + + public ConfigurationOption? Find(string name) => + Options.FirstOrDefault(o => string.Equals(o.Name, name, StringComparison.OrdinalIgnoreCase)); +} + +/// Option name as shown by sp_configure, e.g. max server memory (MB). +/// The configured value (what RECONFIGURE will apply). +/// The value currently in effect. +public sealed record ConfigurationOption(string Name, long Value, long ValueInUse); diff --git a/src/SQLInsight.Core/Data/CpuUtilization.cs b/src/SQLInsight.Core/Data/CpuUtilization.cs new file mode 100644 index 0000000..987bc52 --- /dev/null +++ b/src/SQLInsight.Core/Data/CpuUtilization.cs @@ -0,0 +1,18 @@ +namespace SQLInsight.Core.Data; + +/// +/// CPU use per minute from the scheduler monitor ring buffer (sys.dm_os_ring_buffers, 2005+). +/// Distinguishes SQL Server's own CPU from other processes on the host. +/// +public sealed record CpuUtilization +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// One sample per minute, oldest first. + public required IReadOnlyList Samples { get; init; } +} + +public sealed record CpuSample(DateTimeOffset Time, int SqlServerPercent, int IdlePercent) +{ + public int OtherProcessesPercent => Math.Max(0, 100 - SqlServerPercent - IdlePercent); +} diff --git a/src/SQLInsight.Core/Data/CumulativeSample.cs b/src/SQLInsight.Core/Data/CumulativeSample.cs new file mode 100644 index 0000000..3a5bb9a --- /dev/null +++ b/src/SQLInsight.Core/Data/CumulativeSample.cs @@ -0,0 +1,10 @@ +namespace SQLInsight.Core.Data; + +/// +/// 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 +/// for one collection instead of producing negative or meaningless numbers. +/// +public sealed record CumulativeSample(TSnapshot Snapshot, TDelta? Delta) + where TSnapshot : class + where TDelta : class; diff --git a/src/SQLInsight.Core/Data/DatabaseSnapshot.cs b/src/SQLInsight.Core/Data/DatabaseSnapshot.cs new file mode 100644 index 0000000..1d2a7e0 --- /dev/null +++ b/src/SQLInsight.Core/Data/DatabaseSnapshot.cs @@ -0,0 +1,89 @@ +namespace SQLInsight.Core.Data; + +/// +/// Settings, backups, integrity checks and files for every database on the instance. All times are UTC. +/// +public sealed record DatabaseSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required IReadOnlyList Databases { get; init; } + + /// tempdb's files. tempdb itself is not in : it is recreated at every start. + public IReadOnlyList TempdbFiles { get; init; } = []; +} + +public sealed record DatabaseInfo +{ + public required string Name { get; init; } + public required int DatabaseId { get; init; } + + /// ONLINE, RESTORING, RECOVERING, RECOVERY_PENDING, SUSPECT, EMERGENCY, OFFLINE … + public required string State { get; init; } + + /// FULL, BULK_LOGGED or SIMPLE. + public required string RecoveryModel { get; init; } + + public required int CompatibilityLevel { get; init; } + + /// CHECKSUM, TORN_PAGE_DETECTION or NONE. on SQL Server 2000. + 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; } + + /// True for a readable/non-readable AG secondary or a database mirroring target. + public bool IsReplicaOrMirror { get; init; } + + /// + /// Member of an Always On availability group. Backups may be taken on another replica, so they are not + /// necessarily in this instance's msdb. + /// + 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; } + + /// + /// Last successful DBCC CHECKDB. when the version cannot report it without sysadmin; + /// 1900-01-01 when CHECKDB has never completed. + /// + public DateTime? LastGoodCheckDb { get; init; } + + /// Virtual log files in the transaction log. when not measurable on this version. + public int? VirtualLogFileCount { get; init; } + + /// Query Store state. before 2016 or when the database could not be read. + public QueryStoreState? QueryStore { get; init; } + + public IReadOnlyList Files { get; init; } = []; + + public bool IsSystemDatabase => DatabaseId <= 4; +} + +/// OFF, READ_ONLY or READ_WRITE. +/// OFF, READ_ONLY, READ_WRITE or ERROR. +/// Bit mask from sys.database_query_store_options.readonly_reason. 65536 means the size limit was reached. +public sealed record QueryStoreState(string DesiredState, string ActualState, int ReadOnlyReason, long CurrentStorageMb, long MaxStorageMb); + +public sealed record DatabaseFileInfo +{ + public required string LogicalName { get; init; } + + /// ROWS or LOG. + public required string Type { get; init; } + + public required string PhysicalName { get; init; } + public required long SizeMb { get; init; } + + /// Growth in MB, or in percent when . 0 means autogrowth is off. + public required long Growth { get; init; } + + public required bool IsPercentGrowth { get; init; } + + /// Maximum size in MB, or -1 for unlimited. + public required long MaxSizeMb { get; init; } +} diff --git a/src/SQLInsight.Core/Data/Deadlocks.cs b/src/SQLInsight.Core/Data/Deadlocks.cs new file mode 100644 index 0000000..01fdaa6 --- /dev/null +++ b/src/SQLInsight.Core/Data/Deadlocks.cs @@ -0,0 +1,44 @@ +namespace SQLInsight.Core.Data; + +/// +/// Deadlocks captured by the system_health Extended Events session since the previous collection. +/// +public sealed record DeadlockSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// Deadlocks newer than the previous collection. + public required IReadOnlyList NewDeadlocks { get; init; } + + /// Newest event time seen so far; the next collection starts after it. + public DateTimeOffset? LastEventTime { get; init; } + + /// Where reading stopped in the event file, so the next read does not rescan all files. + public string? LastFileName { get; init; } + + public long? LastFileOffset { get; init; } +} + +public sealed record DeadlockEvent +{ + public required DateTimeOffset Time { get; init; } + public required IReadOnlyList Processes { get; init; } + + /// The raw deadlock graph (XDL), for download and viewing in SSMS. + 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; } +} diff --git a/src/SQLInsight.Core/Data/FileIo.cs b/src/SQLInsight.Core/Data/FileIo.cs new file mode 100644 index 0000000..697e9f3 --- /dev/null +++ b/src/SQLInsight.Core/Data/FileIo.cs @@ -0,0 +1,73 @@ +namespace SQLInsight.Core.Data; + +/// +/// Cumulative I/O per database file (sys.dm_io_virtual_file_stats). +/// +public sealed record FileIoSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required DateTime SqlServerStartTime { get; init; } + public required IReadOnlyList Files { get; init; } +} + +public sealed record FileIoStat +{ + public required string DatabaseName { get; init; } + public required int FileId { get; init; } + + /// ROWS or LOG. + 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; +} + +/// +/// I/O per file between two snapshots. +/// +public sealed record FileIoDelta +{ + public required DateTimeOffset From { get; init; } + public required DateTimeOffset To { get; init; } + public required IReadOnlyList 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(); + 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 }; + } +} diff --git a/src/SQLInsight.Core/Data/MiscSnapshots.cs b/src/SQLInsight.Core/Data/MiscSnapshots.cs new file mode 100644 index 0000000..46fded8 --- /dev/null +++ b/src/SQLInsight.Core/Data/MiscSnapshots.cs @@ -0,0 +1,36 @@ +namespace SQLInsight.Core.Data; + +/// +/// Globally enabled trace flags (DBCC TRACESTATUS(-1)). +/// +public sealed record TraceFlagSnapshot(DateTimeOffset CapturedAt, IReadOnlyList GlobalTraceFlags) +{ + public bool IsEnabled(int flag) => GlobalTraceFlags.Contains(flag); +} + +/// +/// Pages SQL Server has recorded as damaged (msdb.dbo.suspect_pages, 823/824/825 errors). +/// +public sealed record SuspectPageSnapshot(DateTimeOffset CapturedAt, IReadOnlyList Pages); + +/// 1: 823/824 error, 2: bad checksum, 3: torn page, 4: restored, 5: repaired, 7: deallocated. +public sealed record SuspectPage(string DatabaseName, int FileId, long PageId, int EventType, int ErrorCount, DateTime LastUpdate) +{ + /// Event types 4, 5 and 7 mean the page has been fixed. + public bool IsUnresolved => EventType is 1 or 2 or 3; +} + +/// +/// Plan cache composition (sys.dm_exec_cached_plans). +/// +public sealed record PlanCacheSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required long TotalPlans { get; init; } + public required double TotalMb { get; init; } + + /// Ad hoc plans used exactly once. A high share means plan cache bloat. + public required long SingleUseAdhocPlans { get; init; } + + public required double SingleUseAdhocMb { get; init; } +} diff --git a/src/SQLInsight.Core/Data/PerformanceCounters.cs b/src/SQLInsight.Core/Data/PerformanceCounters.cs new file mode 100644 index 0000000..23b6ea9 --- /dev/null +++ b/src/SQLInsight.Core/Data/PerformanceCounters.cs @@ -0,0 +1,56 @@ +namespace SQLInsight.Core.Data; + +/// +/// Selected SQL Server performance counters (sys.dm_os_performance_counters / sysperfinfo). +/// Point-in-time counters are used as-is; per-second counters are cumulative and need a delta. +/// +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; } + + /// Cumulative since start. + public long? BatchRequests { get; init; } + + /// Cumulative since start. + public long? SqlCompilations { get; init; } + + /// Cumulative since start. + 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; +} diff --git a/src/SQLInsight.Core/Data/ServerSnapshot.cs b/src/SQLInsight.Core/Data/ServerSnapshot.cs new file mode 100644 index 0000000..d9848fc --- /dev/null +++ b/src/SQLInsight.Core/Data/ServerSnapshot.cs @@ -0,0 +1,26 @@ +namespace SQLInsight.Core.Data; + +/// +/// Hardware and process facts about the instance. +/// +public sealed record ServerSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// When SQL Server last started. Cumulative counters reset here. + public required DateTime SqlServerStartTime { get; init; } + + public required int LogicalCpuCount { get; init; } + + /// Visible online schedulers (can be fewer than CPUs under affinity or edition limits). 2005+. + public int? VisibleOnlineSchedulers { get; init; } + + /// NUMA nodes with online schedulers. 2008+. + public int? NumaNodeCount { get; init; } + + /// Physical memory of the host in MB. 2005+. + public long? PhysicalMemoryMb { get; init; } + + /// NONE, HYPERVISOR or OTHER. 2008 R2 SP1+. + public string? VirtualMachineType { get; init; } +} diff --git a/src/SQLInsight.Core/Data/WaitCatalog.cs b/src/SQLInsight.Core/Data/WaitCatalog.cs new file mode 100644 index 0000000..7395e96 --- /dev/null +++ b/src/SQLInsight.Core/Data/WaitCatalog.cs @@ -0,0 +1,84 @@ +using System.Collections.Frozen; + +namespace SQLInsight.Core.Data; + +/// +/// 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. +/// +public static class WaitCatalog +{ + private static readonly FrozenSet 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, +} diff --git a/src/SQLInsight.Core/Data/WaitStats.cs b/src/SQLInsight.Core/Data/WaitStats.cs new file mode 100644 index 0000000..29af740 --- /dev/null +++ b/src/SQLInsight.Core/Data/WaitStats.cs @@ -0,0 +1,72 @@ +namespace SQLInsight.Core.Data; + +/// +/// Cumulative wait statistics since the instance started (sys.dm_os_wait_stats / DBCC SQLPERF(WAITSTATS)). +/// +public sealed record WaitStatsSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required DateTime SqlServerStartTime { get; init; } + public required IReadOnlyList Waits { get; init; } +} + +public sealed record WaitStat(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs) +{ + /// Time spent waiting for the resource itself, excluding time waiting for a CPU afterwards. + public long ResourceWaitTimeMs => WaitTimeMs - SignalWaitTimeMs; +} + +/// +/// Waits accumulated between two snapshots, with benign waits removed. +/// +public sealed record WaitStatsDelta +{ + public required DateTimeOffset From { get; init; } + public required DateTimeOffset To { get; init; } + public required IReadOnlyList 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(); + 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(), + }; + } +} diff --git a/src/SQLInsight.Core/Findings/Finding.cs b/src/SQLInsight.Core/Findings/Finding.cs index e97aa33..5f77a4d 100644 --- a/src/SQLInsight.Core/Findings/Finding.cs +++ b/src/SQLInsight.Core/Findings/Finding.cs @@ -9,6 +9,13 @@ public sealed record Finding public required Severity Severity { get; init; } public required string Title { get; init; } + /// + /// What the finding is about when a rule can report several, e.g. database: Sales or file: Sales_log. + /// Together with it identifies the finding across collections, so it can be tracked + /// (first seen, last seen, resolved). for instance-wide findings. + /// + public string? Subject { get; init; } + /// Why this is a problem. public required string Explanation { get; init; } diff --git a/src/SQLInsight.Core/Instances/Capability.cs b/src/SQLInsight.Core/Instances/Capability.cs index 5a39dc7..d0dd29d 100644 --- a/src/SQLInsight.Core/Instances/Capability.cs +++ b/src/SQLInsight.Core/Instances/Capability.cs @@ -41,6 +41,9 @@ public enum Capability /// DATABASEPROPERTYEX(..., 'LastGoodCheckDbTime'), so CHECKDB status does not need sysadmin. SQL Server 2016 SP2+. LastGoodCheckDbTime, + /// sys.dm_db_log_info, so VLFs can be counted without sysadmin. SQL Server 2016 SP2+. + LogInfoDmv, + /// sys.dm_exec_query_plan_stats (last actual execution plan). SQL Server 2019+. LastActualPlan, diff --git a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs index d68a805..d9a534e 100644 --- a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs +++ b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs @@ -20,6 +20,7 @@ public sealed class InstanceCapabilities [Capability.QueryStore] = new(13, 0, 0, 0), [Capability.SessionWaitStats] = new(13, 0, 0, 0), [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.GranularServerPermissions] = new(16, 0, 0, 0), }; diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index 06e6145..f60c927 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -1,5 +1,9 @@ +using SQLInsight.Rules.Concurrency; +using SQLInsight.Rules.Configuration; +using SQLInsight.Rules.Databases; using SQLInsight.Rules.Engine; using SQLInsight.Rules.Lifecycle; +using SQLInsight.Rules.Performance; namespace SQLInsight.Rules; @@ -7,6 +11,42 @@ public static class BuiltInRules { public static IReadOnlyList Create() => [ + // Version 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(), ]; } diff --git a/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs b/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs new file mode 100644 index 0000000..f20353a --- /dev/null +++ b/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs @@ -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; + +/// +/// Blocking chains that have lasted long enough to hurt users. +/// +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(); + var findings = new List(); + 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 + { + 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 AllBlockedBy(BlockingSnapshot snapshot, int headSessionId) + { + var result = new List(); + var queue = new Queue([headSessionId]); + var seen = new HashSet { 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; + } +} + +/// +/// Deadlocks since the previous collection. +/// +public sealed class DeadlockRule : Rule +{ + public override string Id => "SQLI-DLK-001"; + public override string Title => "Deadlocks"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SystemHealthSession]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var deadlocks = context.Data.Get().NewDeadlocks; + if (deadlocks.Count == 0) + { + return RuleEvaluation.Passed(); + } + + var evidence = new List { 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"))); + } +} diff --git a/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs b/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs new file mode 100644 index 0000000..d050a61 --- /dev/null +++ b/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs @@ -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; + +/// +/// A server option with a value that is a known problem. Declarative, so each such piece of expertise is a few lines. +/// +public sealed class ConfigurationOptionRule( + string id, + string option, + Func isProblem, + Severity severity, + string title, + string explanation, + string recommendation, + long? recommendedValue, + Reference reference, + Func? 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().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; + """; +} + +/// +/// The configuration rules that need no more than one option value. +/// +public static class SimpleConfigurationRules +{ + public static IEnumerable 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")), + ]; +} + +/// +/// max server memory left at the default: SQL Server may take all memory and starve Windows. +/// +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(out var server) ? server.PhysicalMemoryMb : null; + var recommended = physical is { } p ? MemoryGuidance.Recommended(p) : (long?)null; + var evidence = new List { 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"))); + } +} + +/// +/// max server memory set so high that the operating system has too little left. +/// +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().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 +{ + /// Memory to leave for the OS: the larger of 4 GB and 10 %. + public static long Reserve(long physicalMb) => Math.Max(4096, physicalMb / 10); + + public static long Recommended(long physicalMb) => Math.Max(physicalMb / 2, physicalMb - Reserve(physicalMb)); +} + +/// +/// Cost threshold for parallelism at the 1990s default of 5. +/// +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().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"))); + } +} + +/// +/// MAXDOP not following Microsoft's guidance for the NUMA layout. +/// +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(); + 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"))); + } + + /// + /// 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. + /// + internal static int Recommended(int nodes, int perNode) => nodes <= 1 + ? Math.Min(8, perNode) + : perNode <= 16 ? perNode : Math.Min(16, perNode / 2); +} + +/// +/// Plan cache full of single-use ad hoc plans without optimize for ad hoc workloads. +/// +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 RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var value = ConfigurationValues.Get(context, Option).ValueInUse; + var cache = context.Data.Get(); + 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"))); + } +} + +/// +/// Options changed with sp_configure but not applied. +/// +public sealed class PendingConfigurationRule : Rule +{ + // Configured 0 while SQL Server reports the effective minimum; not a pending change. + private static readonly HashSet 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().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"))); + } +} + +/// +/// No way to see deadlocks on versions without system_health. +/// +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(); + 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"))); + } +} diff --git a/src/SQLInsight.Rules/Databases/DatabaseRules.cs b/src/SQLInsight.Rules/Databases/DatabaseRules.cs new file mode 100644 index 0000000..edcfcae --- /dev/null +++ b/src/SQLInsight.Rules/Databases/DatabaseRules.cs @@ -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 Online(RuleContext context) => + context.Data.Get().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); +} + +/// +/// A database setting with a value that is a known problem. +/// +public sealed class DatabaseSettingRule( + string id, + string title, + Func isProblem, + Severity severity, + Func findingTitle, + string explanation, + string recommendation, + Func 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 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")), + ]; +} + +/// +/// Databases that are not usable. +/// +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().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)))); + } +} + +/// +/// Compatibility level below the engine's native level. +/// +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")))); + } +} + +/// +/// Databases without a recent full backup. +/// +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(); + 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); + } +} + +/// +/// FULL/BULK_LOGGED databases without log backups: the log grows forever and point-in-time recovery is lost. +/// +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")))); + } +} + +/// +/// DBCC CHECKDB not run recently. +/// +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 RequiredCapabilities => [Capability.LastGoodCheckDbTime]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var findings = new List(); + 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); + } +} + +/// +/// Pages SQL Server has recorded as corrupt. +/// +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 RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + return Result(context.Data.Get().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")))); + } +} + +/// +/// Too many virtual log files slow down recovery, restores and log backups. +/// +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 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")))); + } +} + +/// +/// Autogrowth settings that cause many or unpredictable growths. +/// +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(); + 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); + } +} + +/// +/// Files that will soon hit their maximum size. +/// +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}")))); + } +} + +/// +/// Query Store switched itself to read-only or error. +/// +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 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")); + })); + } +} + +/// +/// tempdb with too few, or unevenly sized, data files. +/// +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().TempdbFiles.Where(f => f.Type == "ROWS").ToList(); + var cpus = context.Data.Get().LogicalCpuCount; + if (files.Count == 0) + { + return RuleEvaluation.Passed(); + } + + var recommended = Math.Min(8, cpus); + var findings = new List(); + 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); + } +} diff --git a/src/SQLInsight.Rules/Engine/Rule.cs b/src/SQLInsight.Rules/Engine/Rule.cs new file mode 100644 index 0000000..1d1b4cc --- /dev/null +++ b/src/SQLInsight.Rules/Engine/Rule.cs @@ -0,0 +1,66 @@ +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Rules.Engine; + +/// +/// Convenience base class for rules. +/// +public abstract class Rule : IRule +{ + public abstract string Id { get; } + public abstract string Title { get; } + public virtual IReadOnlyCollection RequiredCapabilities => []; + + public abstract RuleEvaluation Evaluate(RuleContext context); + + protected Finding CreateFinding( + Severity severity, + string title, + string explanation, + string recommendation, + IEnumerable? 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 findings) + { + var list = findings.ToArray(); + return list.Length == 0 ? RuleEvaluation.Passed() : RuleEvaluation.Found(list); + } +} + +/// +/// Links to Microsoft's documentation. +/// +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"; +} diff --git a/src/SQLInsight.Rules/Engine/RuleContext.cs b/src/SQLInsight.Rules/Engine/RuleContext.cs index 8bcb6e2..4a42ead 100644 --- a/src/SQLInsight.Rules/Engine/RuleContext.cs +++ b/src/SQLInsight.Rules/Engine/RuleContext.cs @@ -1,5 +1,16 @@ +using SQLInsight.Core.Collection; using SQLInsight.Core.Instances; namespace SQLInsight.Rules.Engine; -public sealed record RuleContext(InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now); +/// +/// The latest collected data. Rules read it with ; missing data makes the rule +/// not measurable instead of passing. +/// +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()) + { + } +} diff --git a/src/SQLInsight.Rules/Engine/RuleEngine.cs b/src/SQLInsight.Rules/Engine/RuleEngine.cs index ab51513..65e6dbc 100644 --- a/src/SQLInsight.Rules/Engine/RuleEngine.cs +++ b/src/SQLInsight.Rules/Engine/RuleEngine.cs @@ -1,3 +1,4 @@ +using SQLInsight.Core.Collection; using SQLInsight.Core.Instances; namespace SQLInsight.Rules.Engine; @@ -55,6 +56,16 @@ public sealed class RuleEngine 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. catch (Exception ex) #pragma warning restore CA1031 diff --git a/src/SQLInsight.Rules/Performance/ResourceRules.cs b/src/SQLInsight.Rules/Performance/ResourceRules.cs new file mode 100644 index 0000000..7532f7f --- /dev/null +++ b/src/SQLInsight.Rules/Performance/ResourceRules.cs @@ -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>().Delta + ?? throw new DataUnavailableException("I/O-latens kræver to målinger; første måling er taget (eller instansen er genstartet)."); +} + +/// +/// Slow reads from data files. +/// +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 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")))); + } +} + +/// +/// Slow writes to transaction log files. +/// +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 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")))); + } +} + +/// +/// Sustained high CPU, split into SQL Server's own and other processes'. +/// +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 RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var samples = context.Data.Get().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(); + 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); + } +} + +/// +/// Queries waiting for a memory grant before they can even start. +/// +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>().Snapshot; + long semaphoreMs = 0; + if (context.Data.TryGet>(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"))); + } +} diff --git a/src/SQLInsight.Rules/Performance/WaitRules.cs b/src/SQLInsight.Rules/Performance/WaitRules.cs new file mode 100644 index 0000000..e0bab32 --- /dev/null +++ b/src/SQLInsight.Rules/Performance/WaitRules.cs @@ -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; + +/// +/// Wait statistics for the last interval, summarized per category. +/// +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>(); + 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 In(WaitCategory category) => Delta.Waits.Where(w => WaitCatalog.Categorize(w.WaitType) == category); + + public long WaitMs(WaitCategory category) => In(category).Sum(w => w.WaitTimeMs); + + /// Average number of tasks waiting on this category at any moment (wait ms per wall-clock ms). + 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(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"))); +} + +/// +/// 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. +/// +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 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")), + ]; +} + +/// +/// THREADPOOL: SQL Server ran out of worker threads. New connections hang; this is an outage in progress. +/// +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"))); + } +} + +/// +/// Slow transaction log writes, measured as average WRITELOG wait per commit. +/// +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"))); + } +} + +/// +/// High signal wait ratio: tasks are ready to run but wait for a CPU. +/// +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"))); + } +} diff --git a/src/SQLInsight.Rules/SQLInsight.Rules.csproj b/src/SQLInsight.Rules/SQLInsight.Rules.csproj index 3979a7d..bfae138 100644 --- a/src/SQLInsight.Rules/SQLInsight.Rules.csproj +++ b/src/SQLInsight.Rules/SQLInsight.Rules.csproj @@ -5,6 +5,9 @@ + + + diff --git a/tests/SQLInsight.Collectors.Tests/DeadlockParserTests.cs b/tests/SQLInsight.Collectors.Tests/DeadlockParserTests.cs new file mode 100644 index 0000000..dfecc82 --- /dev/null +++ b/tests/SQLInsight.Collectors.Tests/DeadlockParserTests.cs @@ -0,0 +1,69 @@ +using System.Xml.Linq; +using SQLInsight.Collectors.Concurrency; + +namespace SQLInsight.Collectors.Tests; + +public class DeadlockParserTests +{ + private const string Graph = """ + + + + + UPDATE dbo.Orders SET Status = 2 WHERE Id = @id + + + UPDATE dbo.Orders SET Status = 3 WHERE Id = @id + + + + + """; + + [Fact] + public void Parses_event_file_format() + { + var evt = XElement.Parse($""" + + {Graph} + + """); + + 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.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($""" + + {escaped} + + """); + + var deadlock = DeadlockParser.ParseEvent(evt)!; + + Assert.Equal(2, deadlock.Processes.Count); + } + + [Fact] + public void Garbage_is_ignored() + { + var evt = XElement.Parse("""not xml"""); + + Assert.Null(DeadlockParser.ParseEvent(evt)); + } +} diff --git a/tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj b/tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj new file mode 100644 index 0000000..9693431 --- /dev/null +++ b/tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/SQLInsight.Collectors.Tests/SqlSelectionTests.cs b/tests/SQLInsight.Collectors.Tests/SqlSelectionTests.cs new file mode 100644 index 0000000..1be0af8 --- /dev/null +++ b/tests/SQLInsight.Collectors.Tests/SqlSelectionTests.cs @@ -0,0 +1,68 @@ +using SQLInsight.Collectors.Concurrency; +using SQLInsight.Collectors.Databases; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Tests; + +/// +/// The version-specific SQL that cannot be run in CI (SQL Server 2000–2016 have no containers). +/// +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); + } +} diff --git a/tests/SQLInsight.Core.Tests/DeltaTests.cs b/tests/SQLInsight.Core.Tests/DeltaTests.cs new file mode 100644 index 0000000..5dd862b --- /dev/null +++ b/tests/SQLInsight.Core.Tests/DeltaTests.cs @@ -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)); + } +} diff --git a/tests/SQLInsight.IntegrationTests/AgentSession.cs b/tests/SQLInsight.IntegrationTests/AgentSession.cs new file mode 100644 index 0000000..1d45614 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/AgentSession.cs @@ -0,0 +1,47 @@ +using SQLInsight.Collectors; +using SQLInsight.Connectivity; +using SQLInsight.Core.Instances; + +namespace SQLInsight.IntegrationTests; + +/// +/// A monitoring connection as the least-privilege agent login, opened exactly like the agent does. +/// +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 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 CollectAsync(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(); +} diff --git a/tests/SQLInsight.IntegrationTests/CollectorTests.cs b/tests/SQLInsight.IntegrationTests/CollectorTests.cs new file mode 100644 index 0000000..98015d5 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/CollectorTests.cs @@ -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; + +/// +/// Every collector against real SQL Server, as the least-privilege agent login. +/// +[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(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(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(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(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>(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>(new FileIoCollector()); + var io2 = await session.CollectAsync(new FileIoCollector(), io1); + var pc1 = await session.CollectAsync>(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(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(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(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(" 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 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(); + } +} diff --git a/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs b/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs index f10f568..ee8dba4 100644 --- a/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs +++ b/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs @@ -8,34 +8,9 @@ namespace SQLInsight.IntegrationTests; /// [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] -public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime +public class LeastPrivilegeTests(SqlServerFixture sql) { - private const string Login = "sqlinsight_agent"; - 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; + private const string Database = SqlServerFixture.TestDatabase; [Fact] public async Task Agent_login_is_not_sysadmin() @@ -84,21 +59,12 @@ public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime [Fact] public async Task Script_is_idempotent() { - 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); + await sql.ExecuteAsSaAsync(await SqlServerFixture.GrantScriptAsync()); } private async Task ScalarAsync(string query) { - var builder = new SqlConnectionStringBuilder(sql.SaConnectionString) - { - UserID = Login, - Password = Password, - InitialCatalog = "master", - }; - await using var connection = new SqlConnection(builder.ConnectionString); + await using var connection = new SqlConnection(sql.AgentConnectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = query; diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj index a885e5b..c9d069d 100644 --- a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj +++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs b/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs index 5510a0d..eb4f9ab 100644 --- a/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs +++ b/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs @@ -4,12 +4,15 @@ using Testcontainers.MsSql; namespace SQLInsight.IntegrationTests; /// -/// One SQL Server container for the test run. The image is chosen with SQLINSIGHT_MSSQL_IMAGE so CI can run -/// the same tests against 2017, 2019, 2022 and 2025. +/// One SQL Server container for the test run, with the agent login set up by the real least-privilege script. +/// The image is chosen with SQLINSIGHT_MSSQL_IMAGE so CI can run the same tests against 2017–2025. /// public sealed class SqlServerFixture : IAsyncLifetime { 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; @@ -17,16 +20,42 @@ public sealed class SqlServerFixture : IAsyncLifetime 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 - { - var builder = new SqlConnectionStringBuilder(SaConnectionString); - return builder.DataSource; - } + UserID = AgentLogin, + Password = AgentPassword, + InitialCatalog = "master", + }.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 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(); diff --git a/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs b/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs new file mode 100644 index 0000000..e1fd9d3 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs @@ -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); + } +} diff --git a/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs b/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs new file mode 100644 index 0000000..25494bf --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs @@ -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); + } +} diff --git a/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs b/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs new file mode 100644 index 0000000..e8d1cdf --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs @@ -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(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(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 = "", + 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); + } +} diff --git a/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs b/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs index a909351..9e90316 100644 --- a/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs +++ b/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs @@ -69,6 +69,23 @@ public class RuleEngineTests 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(); + return RuleEvaluation.Passed(); + }); + var context = TestInstances.Context("16.0.4135.4"); + context.Data.MarkUnavailable("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] public void Duplicate_rule_ids_are_rejected() { diff --git a/tests/SQLInsight.Rules.Tests/TestInstances.cs b/tests/SQLInsight.Rules.Tests/TestInstances.cs index 299a518..eb888e5 100644 --- a/tests/SQLInsight.Rules.Tests/TestInstances.cs +++ b/tests/SQLInsight.Rules.Tests/TestInstances.cs @@ -1,3 +1,4 @@ +using SQLInsight.Core.Data; using SQLInsight.Core.Instances; using SQLInsight.Rules.Engine; @@ -5,7 +6,9 @@ namespace SQLInsight.Rules.Tests; 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 { @@ -13,8 +16,85 @@ internal static class TestInstances Version = SqlServerVersion.Parse(version), ProductLevel = "RTM", 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); } + + /// SQL Server 2022 with the given data collected. + 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? 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 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 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, + }; }