From ac13e3d82686da82e2d893d92f8a6db7680e97c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 08:03:11 +0200 Subject: [PATCH] Disk speed per drive: IOPS, MB/s and latency - file-io records each file's drive: sys.dm_os_volume_stats from 2008 R2 SP1 (new capability VolumeStats, mount points included), otherwise derived from the path (D:\..., \server\share\, / on Linux), so 2005 works too - migration 0007: Volume and IntervalSeconds on FileIoSamples, and DiskHourly (rolled up by maintenance, kept HourlyDays) - DiskActivity sums files per drive: read/write IOPS, MB/s and latency weighted by I/O count - instance page: "Diskhastighed pr. drev" (table and MB/s + latency charts); time window analysis and its PDF: per drive against the same hours of the four previous weeks - SQLI-IO-001/002 now report per drive (one finding when the storage is slow, the slowest files in the evidence) instead of per file Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/analyse.md | 14 +++- .../Performance/FileIoCollector.cs | 43 +++++++--- src/SQLInsight.Core/Data/FileIo.cs | 70 ++++++++++++++++ src/SQLInsight.Core/Instances/Capability.cs | 3 + .../Instances/InstanceCapabilities.cs | 1 + src/SQLInsight.Reports/TimeWindow.cs | 34 ++++++++ src/SQLInsight.Reports/TimeWindowAnalysis.cs | 13 ++- src/SQLInsight.Reports/TimeWindowReport.cs | 24 ++++++ .../InsightRepository.Disks.cs | 63 ++++++++++++++ .../InsightRepository.Maintenance.cs | 19 +++++ .../InsightRepository.cs | 14 +++- .../Migrations/0007_disk_volumes.sql | 21 +++++ .../Performance/ResourceRules.cs | 84 +++++++++++++------ .../Components/Pages/Analysis.razor | 5 ++ .../Components/Pages/Instance.razor | 19 +++++ .../Components/Shared/DiskTable.razor | 48 +++++++++++ tests/SQLInsight.Core.Tests/DiskTests.cs | 64 ++++++++++++++ .../CollectorTests.cs | 2 + .../TimeWindowAnalysisTests.cs | 46 ++++++++++ .../PerformanceRuleTests.cs | 16 ++++ 20 files changed, 559 insertions(+), 44 deletions(-) create mode 100644 src/SQLInsight.Repository/InsightRepository.Disks.cs create mode 100644 src/SQLInsight.Repository/Migrations/0007_disk_volumes.sql create mode 100644 src/SQLInsight.Web/Components/Shared/DiskTable.razor create mode 100644 tests/SQLInsight.Core.Tests/DiskTests.cs diff --git a/docs/analyse.md b/docs/analyse.md index 1a71a4c..3c8feda 100644 --- a/docs/analyse.md +++ b/docs/analyse.md @@ -13,6 +13,7 @@ Angiv fra og til (i webbrugerfladens tidszone, `SQLInsight:Web:TimeZone`), eller | Belastning | Forløb af CPU (SQL Server og andre processer), batch requests og compilations pr. sekund, page life expectancy, brugerforbindelser, memory grants pending og blokerede processer | | Største ventetider | Ventetid pr. minut i tidsrummet, sammenlignet med normalen. Harmløse baggrundsventetider er udeladt | | Tungeste forespørgsler | De forespørgsler, der brugte mest CPU, tid, læsninger eller blev kørt flest gange i tidsrummet, med tekst og database | +| Diskhastighed pr. drev | Læse- og skrive-IOPS, MB/s og gennemsnitlig latens pr. drev, sammenlignet med normalen (se nedenfor) | | I/O-latens pr. fil | Læsninger, skrivninger og gennemsnitlig latens pr. databasefil | | Blocking og deadlocks | Blocking-kæder over 5 sekunder (head blocker, antal blokerede, længste ventetid) og deadlocks med graf (.xdl) | | Fund | Fund, der var aktive på et tidspunkt i tidsrummet | @@ -26,11 +27,22 @@ Kun tidsrum, hvor agenten kørte og data stadig er gemt: | Data | Opbevaring (standard, `SQLInsight:Retention`) | |---|---| | Målinger hvert 5. minut: belastning, ventetider, I/O, forespørgsler | 14 dage (`RawDays`) | -| Timeværdier: belastning og ventetider | 400 dage (`HourlyDays`) | +| Timeværdier: belastning, ventetider og diskhastighed pr. drev | 400 dage (`HourlyDays`) | | Blocking og deadlocks | 90 dage (`EventDays`) | Ligger tidsrummet længere tilbage end `RawDays`, bruges timeværdier. Det står på siden. Så er der ingen I/O pr. fil og ingen forespørgsler, men belastning og ventetider kan stadig sammenlignes. +## Diskhastighed pr. drev + +Agenten læser hvert 5. minut, hvor meget hver databasefil har læst og skrevet (`sys.dm_io_virtual_file_stats`: antal I/O'er, bytes og ventetid), og lægger filerne sammen pr. drev: + +- **IOPS** = læsninger eller skrivninger pr. sekund, **MB/s** = data pr. sekund, **latens** = gennemsnitlig ventetid pr. læsning eller skrivning, vægtet efter antal I/O'er. +- Drevet kommer fra `sys.dm_os_volume_stats` (SQL Server 2008 R2 SP1 og nyere), så mount points vises rigtigt. På ældre versioner udledes det af filens sti (`D:\…` → `D:\`), og et mount point under et drevbogstav tælles da med drevet. +- Det er tal set fra SQL Server: kun SQL Servers egne filer, ikke andre programmer på samme drev. +- Instanssiden viser de seneste 24 timer med graf over MB/s og latens pr. drev. Fundene SQLI-IO-001 (læselatens på datafiler) og SQLI-IO-002 (skrivelatens på logfiler) gælder pr. drev, med de langsomste filer som målinger. + +Et drev markeres med rødt, når datafiler har over 20 ms pr. læsning, logfiler over 5 ms pr. skrivning, eller latensen er mindst dobbelt så høj som normalt. + ## Tungeste forespørgsler Agenten læser `sys.dm_exec_query_stats` hvert 5. minut (collector `query-stats`, kan ændres med `SQLInsight:CollectorIntervals`) og gemmer, hvad hver forespørgsel brugte siden sidst: diff --git a/src/SQLInsight.Collectors/Performance/FileIoCollector.cs b/src/SQLInsight.Collectors/Performance/FileIoCollector.cs index 83a52f0..feea591 100644 --- a/src/SQLInsight.Collectors/Performance/FileIoCollector.cs +++ b/src/SQLInsight.Collectors/Performance/FileIoCollector.cs @@ -8,12 +8,25 @@ public sealed class FileIoCollector : CollectorWith the real volume (mount points included), from 2008 R2 SP1. + internal const string SqlWithVolumes = """ + 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, + vs.volume_mount_point + 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 + OUTER APPLY sys.dm_os_volume_stats(vfs.database_id, vfs.file_id) vs + 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]; @@ -23,18 +36,24 @@ public sealed class FileIoCollector : Collector new FileIoStat + var sql = context.Capabilities.Has(Capability.VolumeStats) ? SqlWithVolumes : Sql; + var files = await Db.QueryAsync(context.Connection, sql, r => { - 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), + var physicalName = r.String(3); + return new FileIoStat + { + DatabaseName = r.NullableString(0) ?? "?", + FileId = r.Int(1), + FileType = r.String(2), + PhysicalName = physicalName, + Reads = r.Long(4), + BytesRead = r.Long(5), + ReadStallMs = r.Long(6), + Writes = r.Long(7), + BytesWritten = r.Long(8), + WriteStallMs = r.Long(9), + Volume = r.NullableString(10) is { Length: > 0 } volume ? volume : DiskVolumes.FromPhysicalName(physicalName), + }; }, ct).ConfigureAwait(false); var snapshot = new FileIoSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Files = files }; diff --git a/src/SQLInsight.Core/Data/FileIo.cs b/src/SQLInsight.Core/Data/FileIo.cs index 697e9f3..d135cca 100644 --- a/src/SQLInsight.Core/Data/FileIo.cs +++ b/src/SQLInsight.Core/Data/FileIo.cs @@ -19,6 +19,13 @@ public sealed record FileIoStat public required string FileType { get; init; } public required string PhysicalName { get; init; } + + /// + /// The volume (drive or mount point) the file lives on, e.g. D:\. From sys.dm_os_volume_stats where + /// the instance has it, otherwise derived from the path (). + /// + public string? Volume { get; init; } + public required long Reads { get; init; } public required long BytesRead { get; init; } public required long ReadStallMs { get; init; } @@ -30,6 +37,69 @@ public sealed record FileIoStat public double? AverageWriteLatencyMs => Writes == 0 ? null : (double)WriteStallMs / Writes; } +/// +/// Which drive a database file is on, when SQL Server cannot say (before 2008 R2 SP1 there is no +/// sys.dm_os_volume_stats). +/// +public static class DiskVolumes +{ + /// + /// D:\Data\x.mdf → D:\; \\nas\sql\x.mdf → \\nas\sql\; a Linux path → /. A mount point + /// under a drive letter cannot be told apart from the drive itself this way. + /// + public static string FromPhysicalName(string physicalName) + { + ArgumentNullException.ThrowIfNull(physicalName); + var path = physicalName.Trim(); + if (path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':') + { + return char.ToUpperInvariant(path[0]) + @":\"; + } + + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 ? $@"\\{parts[0]}\{parts[1]}\" : path; + } + + return path.StartsWith('/') ? "/" : path; + } + + /// The file's volume as reported, or derived from its path. + public static string Of(FileIoStat file) + { + ArgumentNullException.ThrowIfNull(file); + return string.IsNullOrWhiteSpace(file.Volume) ? FromPhysicalName(file.PhysicalName) : file.Volume; + } +} + +/// +/// I/O on one drive over an interval: the sum of its database files. Latency is weighted by the number of I/Os. +/// +public sealed record DiskActivity(string Volume, int DataFiles, int LogFiles, long Reads, long Writes, long BytesRead, long BytesWritten, + long ReadStallMs, long WriteStallMs, double Seconds) +{ + public double? ReadIops => Seconds > 0 ? Reads / Seconds : null; + public double? WriteIops => Seconds > 0 ? Writes / Seconds : null; + public double? ReadMbPerSecond => Seconds > 0 ? BytesRead / 1048576.0 / Seconds : null; + public double? WriteMbPerSecond => Seconds > 0 ? BytesWritten / 1048576.0 / Seconds : null; + public double? AverageReadMs => Reads == 0 ? null : (double)ReadStallMs / Reads; + public double? AverageWriteMs => Writes == 0 ? null : (double)WriteStallMs / Writes; + + /// Groups the files of one interval by drive. + public static IReadOnlyList ByVolume(IEnumerable files, double seconds) + { + ArgumentNullException.ThrowIfNull(files); + return files + .GroupBy(DiskVolumes.Of, StringComparer.OrdinalIgnoreCase) + .Select(g => new DiskActivity(g.Key, g.Count(f => f.FileType != "LOG"), g.Count(f => f.FileType == "LOG"), + g.Sum(f => f.Reads), g.Sum(f => f.Writes), g.Sum(f => f.BytesRead), g.Sum(f => f.BytesWritten), + g.Sum(f => f.ReadStallMs), g.Sum(f => f.WriteStallMs), seconds)) + .OrderBy(d => d.Volume, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} + /// /// I/O per file between two snapshots. /// diff --git a/src/SQLInsight.Core/Instances/Capability.cs b/src/SQLInsight.Core/Instances/Capability.cs index 032a134..86abcf6 100644 --- a/src/SQLInsight.Core/Instances/Capability.cs +++ b/src/SQLInsight.Core/Instances/Capability.cs @@ -64,4 +64,7 @@ public enum Capability /// sys.dm_exec_text_query_plan (cached plans as text, per statement). SQL Server 2005 SP2+. TextQueryPlan, + + /// sys.dm_os_volume_stats and sys.dm_server_services. SQL Server 2008 R2 SP1+. + VolumeStats, } diff --git a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs index 4df5d69..959a46f 100644 --- a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs +++ b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs @@ -13,6 +13,7 @@ public sealed class InstanceCapabilities [Capability.DefaultTrace] = new(9, 0, 0, 0), [Capability.ViewServerStatePermission] = new(9, 0, 0, 0), [Capability.TextQueryPlan] = new(9, 0, 3042, 0), // 2005 SP2 + [Capability.VolumeStats] = new(10, 50, 2500, 0), // 2008 R2 SP1 [Capability.ExtendedEvents] = new(10, 0, 0, 0), [Capability.SystemHealthSession] = new(10, 0, 0, 0), [Capability.SystemHealthFileTarget] = new(11, 0, 0, 0), diff --git a/src/SQLInsight.Reports/TimeWindow.cs b/src/SQLInsight.Reports/TimeWindow.cs index 1ccda8f..6bff26d 100644 --- a/src/SQLInsight.Reports/TimeWindow.cs +++ b/src/SQLInsight.Reports/TimeWindow.cs @@ -162,4 +162,38 @@ public static class WindowBaseline var values = earlierWeeks.SelectMany(w => w).Select(p => p.Value).ToList(); return values.Count == 0 ? null : values.Average(); } + + /// + /// Each drive in the window against the same hours of earlier weeks: all earlier weeks' I/O pooled, so latency is + /// weighted by the number of I/Os and rates by the time measured. + /// + public static IReadOnlyList CompareDisks(IReadOnlyList window, IReadOnlyList> earlierWeeks) + { + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(earlierWeeks); + return window + .Select(d => + { + var earlier = earlierWeeks.SelectMany(w => w).Where(e => string.Equals(e.Volume, d.Volume, StringComparison.OrdinalIgnoreCase)).ToList(); + Core.Data.DiskActivity? normal = earlier.Count == 0 || earlier.Sum(e => e.Seconds) <= 0 + ? null + : new Core.Data.DiskActivity(d.Volume, 0, 0, earlier.Sum(e => e.Reads), earlier.Sum(e => e.Writes), earlier.Sum(e => e.BytesRead), + earlier.Sum(e => e.BytesWritten), earlier.Sum(e => e.ReadStallMs), earlier.Sum(e => e.WriteStallMs), earlier.Sum(e => e.Seconds)); + return new DiskComparison(d, normal); + }) + .ToList(); + } +} + +/// A drive's I/O in the window compared with the same hours of the week in the previous weeks. +public sealed record DiskComparison(Core.Data.DiskActivity Window, Core.Data.DiskActivity? Normal) +{ + /// Data files over 20 ms per read or log files over 5 ms per write: slow whatever the history says. + public bool IsSlow => Window.AverageReadMs >= 20 && Window.Reads >= 100 || Window.AverageWriteMs >= (Window.DataFiles == 0 ? 5 : 20) && Window.Writes >= 100; + + /// Latency at least twice the normal (and noticeably so), or a slow drive. + public bool IsUnusual => IsSlow + || Worse(Window.AverageReadMs, Normal?.AverageReadMs) || Worse(Window.AverageWriteMs, Normal?.AverageWriteMs); + + private static bool Worse(double? now, double? normal) => now is { } n && normal is > 0 and var m && n >= m * 2 && n - m >= 2; } diff --git a/src/SQLInsight.Reports/TimeWindowAnalysis.cs b/src/SQLInsight.Reports/TimeWindowAnalysis.cs index b39483d..5cbba61 100644 --- a/src/SQLInsight.Reports/TimeWindowAnalysis.cs +++ b/src/SQLInsight.Reports/TimeWindowAnalysis.cs @@ -16,6 +16,10 @@ public sealed record TimeWindowAnalysis public required IReadOnlyList Metrics { get; init; } public required IReadOnlyList Waits { get; init; } public required IReadOnlyList Files { get; init; } + + /// Disk speed per drive, against the normal for the same hours. + public IReadOnlyList Disks { get; init; } = []; + public required IReadOnlyList Blocking { get; init; } public required IReadOnlyList Deadlocks { get; init; } public required IReadOnlyList Findings { get; init; } @@ -24,7 +28,7 @@ public sealed record TimeWindowAnalysis /// The heaviest statements. Their text is customer data: only for the local UI and PDF. public required IReadOnlyList TopQueries { get; init; } - public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Blocking.Count > 0 + public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0 || Deadlocks.Count > 0 || TopQueries.Count > 0; /// The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum. @@ -80,11 +84,17 @@ public sealed record TimeWindowAnalysis } var earlierWaits = new List>(); + var earlierDisks = new List>(); foreach (var back in weeks) { earlierWaits.Add(await repository.GetWaitsHourlyAsync(id, from - back, to - back, cancellationToken).ConfigureAwait(false)); + earlierDisks.Add(await repository.GetDiskActivityHourlyAsync(id, from - back, to - back, cancellationToken).ConfigureAwait(false)); } + var windowDisks = resolution == WindowResolution.Raw + ? await repository.GetDiskActivityAsync(id, from, to, cancellationToken).ConfigureAwait(false) + : await repository.GetDiskActivityHourlyAsync(id, from, to, cancellationToken).ConfigureAwait(false); + return new TimeWindowAnalysis { Instance = instance, @@ -95,6 +105,7 @@ public sealed record TimeWindowAnalysis Metrics = metrics, Waits = WindowBaseline.CompareWaits(windowWaits, minutes, earlierWaits), Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false), + Disks = WindowBaseline.CompareDisks(windowDisks, earlierDisks), Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false), Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false), Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false), diff --git a/src/SQLInsight.Reports/TimeWindowReport.cs b/src/SQLInsight.Reports/TimeWindowReport.cs index 0edc584..143d410 100644 --- a/src/SQLInsight.Reports/TimeWindowReport.cs +++ b/src/SQLInsight.Reports/TimeWindowReport.cs @@ -65,6 +65,7 @@ public static class TimeWindowReport Metrics(section, analysis); Waits(section, analysis); TopQueries(section, analysis); + Disks(section, analysis); Files(section, analysis); Blocking(section, analysis, zone); Deadlocks(section, analysis, zone); @@ -144,6 +145,29 @@ public static class TimeWindowReport } } + private static void Disks(Section section, TimeWindowAnalysis analysis) + { + section.AddParagraph("Diskhastighed pr. drev", StyleNames.Heading1); + if (analysis.Disks.Count == 0) + { + section.AddParagraph("Ingen målinger pr. drev i tidsrummet (indsamles fra version 1.0.5).", "Muted"); + return; + } + + var table = Table(section, [3.2, 2.2, 2.2, 2.2, 2.2, 2.6, 2.8]); + Header(table, "Drev", "Læs IOPS", "Skriv IOPS", "Læs MB/s", "Skriv MB/s", "Gns. læs", "Gns. skriv"); + foreach (var d in analysis.Disks) + { + var w = d.Window; + Row(table, d.IsUnusual, w.Volume, Number(w.ReadIops, 1), Number(w.WriteIops, 1), Number(w.ReadMbPerSecond, 1), Number(w.WriteMbPerSecond, 1), + Ms(w.AverageReadMs) + Normal(d.Normal?.AverageReadMs), Ms(w.AverageWriteMs) + Normal(d.Normal?.AverageWriteMs)); + } + + section.AddParagraph("Latens i parentes: det normale for samme klokkeslæt de foregående uger.", "Muted"); + + static string Normal(double? value) => value is { } v ? $" ({v.ToString("N1", Danish)})" : ""; + } + private static void Files(Section section, TimeWindowAnalysis analysis) { section.AddParagraph("I/O-latens pr. fil", StyleNames.Heading1); diff --git a/src/SQLInsight.Repository/InsightRepository.Disks.cs b/src/SQLInsight.Repository/InsightRepository.Disks.cs new file mode 100644 index 0000000..03aef40 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Disks.cs @@ -0,0 +1,63 @@ +using Dapper; +using SQLInsight.Core.Data; + +namespace SQLInsight.Repository; + +/// Throughput and latency of one drive over time, for the charts. +public sealed record DiskSeries(string Volume, IReadOnlyList MbPerSecond, IReadOnlyList LatencyMs); + +public sealed partial class InsightRepository +{ + /// + /// I/O per drive in [from, to], from the 5-minute samples. Each sample's interval counts once per drive, so IOPS and + /// MB/s are averages over the time actually measured. Samples from before disk speed was collected have no drive. + /// + public async Task> GetDiskActivityAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string Volume, int DataFiles, int LogFiles, long Reads, long Writes, long BytesRead, long BytesWritten, long ReadStallMs, long WriteStallMs, int Seconds)>(""" + SELECT Volume, MAX(DataFiles), MAX(LogFiles), SUM(Reads), SUM(Writes), SUM(BytesRead), SUM(BytesWritten), SUM(ReadStallMs), SUM(WriteStallMs), SUM(Seconds) + FROM ( + SELECT Volume, SampledAt, + COUNT(CASE WHEN FileType <> 'LOG' THEN 1 END) AS DataFiles, COUNT(CASE WHEN FileType = 'LOG' THEN 1 END) AS LogFiles, + SUM(Reads) AS Reads, SUM(Writes) AS Writes, SUM(BytesRead) AS BytesRead, SUM(BytesWritten) AS BytesWritten, + SUM(ReadStallMs) AS ReadStallMs, SUM(WriteStallMs) AS WriteStallMs, MAX(IntervalSeconds) AS Seconds + FROM insight.FileIoSamples + WHERE InstanceId = @instanceId AND SampledAt > @from AND SampledAt <= @to AND Volume IS NOT NULL AND IntervalSeconds IS NOT NULL + GROUP BY Volume, SampledAt + ) AS samples + GROUP BY Volume ORDER BY Volume; + """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false); + return rows.Select(r => new DiskActivity(r.Volume, r.DataFiles, r.LogFiles, r.Reads, r.Writes, r.BytesRead, r.BytesWritten, r.ReadStallMs, r.WriteStallMs, r.Seconds)).ToList(); + } + + /// I/O per drive from the hourly history for the hours overlapping [from, to). + public async Task> GetDiskActivityHourlyAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string Volume, long Reads, long Writes, long BytesRead, long BytesWritten, long ReadStallMs, long WriteStallMs, int Seconds)>(""" + SELECT Volume, SUM(Reads), SUM(Writes), SUM(BytesRead), SUM(BytesWritten), SUM(ReadStallMs), SUM(WriteStallMs), SUM(Seconds) + FROM insight.DiskHourly WHERE InstanceId = @instanceId AND [Hour] >= @from AND [Hour] < @to + GROUP BY Volume ORDER BY Volume; + """, new { instanceId, from = FloorHour(from), to = to.UtcDateTime }).ConfigureAwait(false); + return rows.Select(r => new DiskActivity(r.Volume, 0, 0, r.Reads, r.Writes, r.BytesRead, r.BytesWritten, r.ReadStallMs, r.WriteStallMs, r.Seconds)).ToList(); + } + + /// MB/s (read + write) and average latency per drive per sample since . + public async Task> GetDiskSeriesAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string Volume, DateTime SampledAt, long Ios, long Bytes, long StallMs, int Seconds)>(""" + SELECT Volume, SampledAt, SUM(Reads + Writes), SUM(BytesRead + BytesWritten), SUM(ReadStallMs + WriteStallMs), MAX(IntervalSeconds) + FROM insight.FileIoSamples + WHERE InstanceId = @instanceId AND SampledAt >= @from AND Volume IS NOT NULL AND IntervalSeconds IS NOT NULL + GROUP BY Volume, SampledAt ORDER BY Volume, SampledAt; + """, new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.GroupBy(r => r.Volume) + .Select(g => new DiskSeries( + g.Key, + g.Where(r => r.Seconds > 0).Select(r => new MetricPoint(r.SampledAt, r.Bytes / 1048576.0 / r.Seconds)).ToList(), + g.Where(r => r.Ios > 0).Select(r => new MetricPoint(r.SampledAt, (double)r.StallMs / r.Ios)).ToList())) + .ToList(); + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs index 7f409ad..3a3f3d3 100644 --- a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs +++ b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs @@ -53,6 +53,24 @@ public sealed partial class InsightRepository ) AS s ON t.InstanceId = s.InstanceId AND t.[Hour] = s.[Hour] AND t.WaitType = s.WaitType WHEN MATCHED THEN UPDATE SET WaitingTasks = s.WaitingTasks, WaitTimeMs = s.WaitTimeMs, SignalWaitTimeMs = s.SignalWaitTimeMs WHEN NOT MATCHED THEN INSERT (InstanceId, [Hour], WaitType, WaitingTasks, WaitTimeMs, SignalWaitTimeMs) VALUES (s.InstanceId, s.[Hour], s.WaitType, s.WaitingTasks, s.WaitTimeMs, s.SignalWaitTimeMs); + + MERGE insight.DiskHourly AS t + USING ( + SELECT InstanceId, [Hour], Volume, SUM(Reads) AS Reads, SUM(Writes) AS Writes, SUM(BytesRead) AS BytesRead, SUM(BytesWritten) AS BytesWritten, + SUM(ReadStallMs) AS ReadStallMs, SUM(WriteStallMs) AS WriteStallMs, SUM(Seconds) AS Seconds + FROM ( + SELECT InstanceId, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], Volume, SampledAt, + SUM(Reads) AS Reads, SUM(Writes) AS Writes, SUM(BytesRead) AS BytesRead, SUM(BytesWritten) AS BytesWritten, + SUM(ReadStallMs) AS ReadStallMs, SUM(WriteStallMs) AS WriteStallMs, MAX(IntervalSeconds) AS Seconds + FROM insight.FileIoSamples WHERE SampledAt >= @from AND Volume IS NOT NULL AND IntervalSeconds IS NOT NULL + GROUP BY InstanceId, Volume, SampledAt + ) AS samples + GROUP BY InstanceId, [Hour], Volume + ) AS s ON t.InstanceId = s.InstanceId AND t.[Hour] = s.[Hour] AND t.Volume = s.Volume + WHEN MATCHED THEN UPDATE SET Reads = s.Reads, Writes = s.Writes, BytesRead = s.BytesRead, BytesWritten = s.BytesWritten, + ReadStallMs = s.ReadStallMs, WriteStallMs = s.WriteStallMs, Seconds = s.Seconds + WHEN NOT MATCHED THEN INSERT (InstanceId, [Hour], Volume, Reads, Writes, BytesRead, BytesWritten, ReadStallMs, WriteStallMs, Seconds) + VALUES (s.InstanceId, s.[Hour], s.Volume, s.Reads, s.Writes, s.BytesRead, s.BytesWritten, s.ReadStallMs, s.WriteStallMs, s.Seconds); """, new { from }, commandTimeout: 600).ConfigureAwait(false); var raw = utc.AddDays(-options.RawDays); @@ -67,6 +85,7 @@ public sealed partial class InsightRepository await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.DiskHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false); diff --git a/src/SQLInsight.Repository/InsightRepository.cs b/src/SQLInsight.Repository/InsightRepository.cs index e4cc68f..93a82e3 100644 --- a/src/SQLInsight.Repository/InsightRepository.cs +++ b/src/SQLInsight.Repository/InsightRepository.cs @@ -279,12 +279,18 @@ public sealed partial class InsightRepository(string connectionString) private static Task SaveFileIoAsync(SqlConnection connection, int instanceId, FileIoDelta delta) => connection.ExecuteAsync(""" - INSERT insight.FileIoSamples (InstanceId, SampledAt, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten) - SELECT @instanceId, @at, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten + INSERT insight.FileIoSamples (InstanceId, SampledAt, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten, Volume, IntervalSeconds) + SELECT @instanceId, @at, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten, Volume, @interval FROM OPENJSON(@json) WITH (DatabaseName nvarchar(128), FileId int, FileType varchar(10), Reads bigint, ReadStallMs bigint, - Writes bigint, WriteStallMs bigint, BytesRead bigint, BytesWritten bigint) j + Writes bigint, WriteStallMs bigint, BytesRead bigint, BytesWritten bigint, Volume nvarchar(260)) j WHERE NOT EXISTS (SELECT 1 FROM insight.FileIoSamples f WHERE f.InstanceId = @instanceId AND f.SampledAt = @at AND f.DatabaseName = j.DatabaseName AND f.FileId = j.FileId); - """, new { instanceId, at = Utc(delta.To), json = JsonSerializer.Serialize(delta.Files, Json) }); + """, new + { + instanceId, + at = Utc(delta.To), + interval = (int)Math.Round((delta.To - delta.From).TotalSeconds), + json = JsonSerializer.Serialize(delta.Files.Select(f => f with { Volume = DiskVolumes.Of(f) }), Json), + }); private static Task InsertMetricsAsync(SqlConnection connection, int instanceId, IEnumerable<(string Metric, DateTimeOffset At, double Value)> metrics) => connection.ExecuteAsync(""" diff --git a/src/SQLInsight.Repository/Migrations/0007_disk_volumes.sql b/src/SQLInsight.Repository/Migrations/0007_disk_volumes.sql new file mode 100644 index 0000000..040be4d --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0007_disk_volumes.sql @@ -0,0 +1,21 @@ +/* Disk speed per drive: which volume each file sample belongs to, and how long the sample interval was (for IOPS and + MB/s). NULL on samples taken before this version. */ +ALTER TABLE insight.FileIoSamples ADD Volume nvarchar(260) NULL, IntervalSeconds int NULL; +GO + +/* Hourly I/O per drive, kept as long as the other hourly aggregates (Retention:HourlyDays). Seconds = the measured + time within the hour, so rates stay right when samples are missing. */ +CREATE TABLE insight.DiskHourly +( + InstanceId int NOT NULL, + [Hour] datetime2(0) NOT NULL, + Volume nvarchar(260) NOT NULL, + Reads bigint NOT NULL, + Writes bigint NOT NULL, + BytesRead bigint NOT NULL, + BytesWritten bigint NOT NULL, + ReadStallMs bigint NOT NULL, + WriteStallMs bigint NOT NULL, + Seconds int NOT NULL, + CONSTRAINT PK_DiskHourly PRIMARY KEY (InstanceId, [Hour], Volume) +); diff --git a/src/SQLInsight.Rules/Performance/ResourceRules.cs b/src/SQLInsight.Rules/Performance/ResourceRules.cs index 598d5cb..ad69a10 100644 --- a/src/SQLInsight.Rules/Performance/ResourceRules.cs +++ b/src/SQLInsight.Rules/Performance/ResourceRules.cs @@ -14,65 +14,97 @@ internal static class FileIo } /// -/// Slow reads from data files. +/// Slow reads from the data files on a drive. Per drive rather than per file: when storage is slow, every file on it +/// is, and one finding per drive says so once. The slowest files are in the evidence. /// 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 string Title => "Høj læselatens på drev med datafiler"; public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var delta = FileIo.Delta(context); + var seconds = (delta.To - delta.From).TotalSeconds; 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.", + .Where(f => f.FileType == "ROWS") + .GroupBy(DiskVolumes.Of, StringComparer.OrdinalIgnoreCase) + .Select(g => (Files: g.ToList(), Disk: DiskActivity.ByVolume(g, seconds)[0])) + .Where(d => d.Disk.Reads >= MinReads && d.Disk.AverageReadMs >= 20) + .Select(d => CreateFinding( + d.Disk.AverageReadMs >= 50 ? Severity.High : Severity.Medium, + $"{d.Disk.Volume} (datafiler): {Format.Number(d.Disk.AverageReadMs!.Value, 1)} ms i gennemsnit pr. læsning", + "Læsninger fra datafilerne på drevet 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 hukommelsen (buffer pool), " + + "så brugerne oplever langsomme skærmbilleder og rapporter.", "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}", + + "opstår i storage eller undervejs (HBA, multipath, VM-host). Ser storage-laget hurtigt ud, er drevet overbelastet " + + "af antallet af læsninger: se læse-IOPS og MB/s, og undersøg om de kan reduceres med bedre indexes eller mere hukommelse.", + Evidence(d.Disk, d.Files, read: true, seconds), + subject: $"volume: {d.Disk.Volume} data", references: Docs.Learn("sys.dm_io_virtual_file_stats", "relational-databases/system-dynamic-management-views/sys-dm-io-virtual-file-stats-transact-sql")))); } + + /// Drive totals plus the three slowest files, shared with the log write rule. + internal static Evidence[] Evidence(DiskActivity disk, IReadOnlyList files, bool read, double seconds) + { + var slowest = files + .Where(f => read ? f.Reads > 0 : f.Writes > 0) + .OrderByDescending(f => read ? f.AverageReadLatencyMs : f.AverageWriteLatencyMs) + .Take(3) + .Select(f => $"{f.DatabaseName} {Format.Number((read ? f.AverageReadLatencyMs : f.AverageWriteLatencyMs)!.Value, 1)} ms ({f.PhysicalName})"); + return read + ? + [ + new("Drev", disk.Volume), new("Læsninger", Format.Number(disk.Reads)), + new("Gns. latens", $"{Format.Number(disk.AverageReadMs!.Value, 1)} ms"), + new("Læse-IOPS", Format.Number(disk.ReadIops ?? 0, 1)), new("Læst", $"{Format.Number(disk.ReadMbPerSecond ?? 0, 1)} MB/s"), + new("Langsomste filer", string.Join("; ", slowest)), new("Interval", $"{Format.Number(seconds / 60)} min"), + ] + : + [ + new("Drev", disk.Volume), new("Skrivninger", Format.Number(disk.Writes)), + new("Gns. latens", $"{Format.Number(disk.AverageWriteMs!.Value, 1)} ms"), + new("Skrive-IOPS", Format.Number(disk.WriteIops ?? 0, 1)), new("Skrevet", $"{Format.Number(disk.WriteMbPerSecond ?? 0, 1)} MB/s"), + new("Langsomste filer", string.Join("; ", slowest)), new("Interval", $"{Format.Number(seconds / 60)} min"), + ]; + } } /// -/// Slow writes to transaction log files. +/// Slow writes to the transaction log files on a drive (per drive, like ). /// 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 string Title => "Høj skrivelatens på drev med logfiler"; public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var delta = FileIo.Delta(context); + var seconds = (delta.To - delta.From).TotalSeconds; 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", + .Where(f => f.FileType == "LOG") + .GroupBy(DiskVolumes.Of, StringComparer.OrdinalIgnoreCase) + .Select(g => (Files: g.ToList(), Disk: DiskActivity.ByVolume(g, seconds)[0])) + .Where(d => d.Disk.Writes >= MinWrites && d.Disk.AverageWriteMs >= 5) + .Select(d => CreateFinding( + d.Disk.AverageWriteMs >= 15 ? Severity.High : Severity.Medium, + $"{d.Disk.Volume} (logfiler): {Format.Number(d.Disk.AverageWriteMs!.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}", + + "transaktioner mærkbart langsommere, fx når brugerne bogfører eller gemmer.", + "Placér logfilerne på storage med lav skrivelatens og write cache med batteribackup, adskilt fra datafilerne. " + + "Tjek, om andre filer eller VM'er deler samme drev, og om skrive-MB/s nærmer sig det, drevet kan levere.", + DataFileReadLatencyRule.Evidence(d.Disk, d.Files, read: false, seconds), + subject: $"volume: {d.Disk.Volume} log", references: Docs.Learn("sys.dm_io_virtual_file_stats", "relational-databases/system-dynamic-management-views/sys-dm-io-virtual-file-stats-transact-sql")))); } } diff --git a/src/SQLInsight.Web/Components/Pages/Analysis.razor b/src/SQLInsight.Web/Components/Pages/Analysis.razor index c139f2e..c617d82 100644 --- a/src/SQLInsight.Web/Components/Pages/Analysis.razor +++ b/src/SQLInsight.Web/Components/Pages/Analysis.razor @@ -115,6 +115,11 @@ } +
+

Diskhastighed pr. drev

+ +
+

I/O-latens pr. fil

@if (_analysis.Files.Count == 0) diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index 2f83a42..b76e4ff 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -75,6 +75,21 @@ else }
+
+

Diskhastighed pr. drev, seneste 24 timer

+ +@if (_diskSeries.Count > 0) +{ +
+ @foreach (var s in _diskSeries.Take(6)) + { +
+
+ } +
+} +
+

I/O-latens pr. fil, seneste 24 timer

@if (_files.Count == 0) @@ -223,6 +238,8 @@ else private IReadOnlyList _cpuSql = [], _cpuOther = [], _batches = [], _ple = []; private IReadOnlyList _waits = []; private IReadOnlyList _files = []; + private IReadOnlyList _disks = []; + private IReadOnlyList _diskSeries = []; private IReadOnlyList _blocking = []; private IReadOnlyList _deadlocks = []; @@ -247,6 +264,8 @@ else _ple = await Repository.GetMetricAsync(id, Metrics.PageLifeExpectancy, day); _waits = await Repository.GetTopWaitsAsync(id, day, 12); _files = await Repository.GetFileLatencyAsync(id, day); + _disks = (await Repository.GetDiskActivityAsync(id, day, DateTimeOffset.UtcNow)).Select(d => new SQLInsight.Reports.DiskComparison(d, null)).ToList(); + _diskSeries = await Repository.GetDiskSeriesAsync(id, day); _blocking = await Repository.GetBlockingAsync(id, day); _deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7)); } diff --git a/src/SQLInsight.Web/Components/Shared/DiskTable.razor b/src/SQLInsight.Web/Components/Shared/DiskTable.razor new file mode 100644 index 0000000..80eae69 --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/DiskTable.razor @@ -0,0 +1,48 @@ +@* Disk speed per drive: IOPS, MB/s and average latency, optionally against the normal for the same hours. *@ +@if (Disks.Count == 0) +{ +

@Empty

+} +else +{ + + + + + + + + + @foreach (var d in Disks) + { + var w = d.Window; + + + + + + + + + + + } + +
DrevFilerLæs IOPSSkriv IOPSLæs MB/sSkriv MB/sGns. læsGns. skriv
@w.Volume@Files(w)@Display.Number(w.ReadIops ?? 0, 1)@Display.Number(w.WriteIops ?? 0, 1)@Display.Number(w.ReadMbPerSecond ?? 0, 1)@Display.Number(w.WriteMbPerSecond ?? 0, 1)@Ms(w.AverageReadMs)@Normal(d.Normal?.AverageReadMs)@Ms(w.AverageWriteMs)@Normal(d.Normal?.AverageWriteMs)
+

Latens er gennemsnitstiden pr. læsning/skrivning set fra SQL Server (sys.dm_io_virtual_file_stats). Datafiler bør ligge under 10-20 ms, logfiler under 5 ms.@(ShowNormal ? " I parentes: det normale for samme klokkeslæt de foregående uger." : "")

+} + +@code { + [Parameter, EditorRequired] public IReadOnlyList Disks { get; set; } = []; + [Parameter] public string Empty { get; set; } = "Ingen målinger pr. drev endnu."; + [Parameter] public bool ShowNormal { get; set; } + + private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–"; + + private string Normal(double? value) => ShowNormal && value is { } v ? $" ({Display.Number(v, 1)})" : ""; + + private static string Files(SQLInsight.Core.Data.DiskActivity d) => + d.DataFiles + d.LogFiles == 0 ? "–" : $"{d.DataFiles} data · {d.LogFiles} log"; + + private static bool Worse(double? now, double? normal) => now is { } n && normal is > 0 and var m && n >= m * 2 && n - m >= 2; +} diff --git a/tests/SQLInsight.Core.Tests/DiskTests.cs b/tests/SQLInsight.Core.Tests/DiskTests.cs new file mode 100644 index 0000000..a5bb8ae --- /dev/null +++ b/tests/SQLInsight.Core.Tests/DiskTests.cs @@ -0,0 +1,64 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Core.Tests; + +public class DiskTests +{ + [Theory] + [InlineData(@"D:\SQLData\Sales.mdf", @"D:\")] + [InlineData(@"l:\Logs\Sales_log.ldf", @"L:\")] + [InlineData(@"\\nas01\sql\Sales.mdf", @"\\nas01\sql\")] + [InlineData("/var/opt/mssql/data/master.mdf", "/")] + public void Drive_is_derived_from_the_file_path(string physicalName, string volume) => + Assert.Equal(volume, DiskVolumes.FromPhysicalName(physicalName)); + + [Fact] + public void Reported_volume_wins_over_the_path() + { + var mountPoint = File("Sales", 1, "ROWS", @"D:\Mounts\Data1\Sales.mdf") with { Volume = @"D:\Mounts\Data1\" }; + + Assert.Equal(@"D:\Mounts\Data1\", DiskVolumes.Of(mountPoint)); + Assert.Equal(@"D:\", DiskVolumes.Of(mountPoint with { Volume = null })); + } + + [Fact] + public void Files_are_summed_per_drive_with_rates_and_weighted_latency() + { + var files = new[] + { + File("Sales", 1, "ROWS", @"D:\Sales.mdf", reads: 3000, bytesRead: 300 * 1048576L, readStall: 30_000), + File("Stock", 1, "ROWS", @"D:\Stock.mdf", reads: 1000, bytesRead: 0, readStall: 50_000), + File("Sales", 2, "LOG", @"L:\Sales_log.ldf", writes: 6000, bytesWritten: 60 * 1048576L, writeStall: 12_000), + }; + + var disks = DiskActivity.ByVolume(files, seconds: 300); + + Assert.Equal([@"D:\", @"L:\"], disks.Select(d => d.Volume)); + var data = disks[0]; + Assert.Equal(2, data.DataFiles); + Assert.Equal(0, data.LogFiles); + Assert.Equal(4000 / 300.0, data.ReadIops); + Assert.Equal(1.0, data.ReadMbPerSecond); + Assert.Equal(20, data.AverageReadMs); // 80 s over 4000 reads, not the average of 10 and 50 ms + Assert.Null(data.AverageWriteMs); + var log = disks[1]; + Assert.Equal(20, log.WriteIops); + Assert.Equal(0.2, log.WriteMbPerSecond!.Value, 6); + Assert.Equal(2, log.AverageWriteMs); + } + + private static FileIoStat File(string db, int id, string type, string path, long reads = 0, long bytesRead = 0, long readStall = 0, + long writes = 0, long bytesWritten = 0, long writeStall = 0) => new() + { + DatabaseName = db, + FileId = id, + FileType = type, + PhysicalName = path, + Reads = reads, + BytesRead = bytesRead, + ReadStallMs = readStall, + Writes = writes, + BytesWritten = bytesWritten, + WriteStallMs = writeStall, + }; +} diff --git a/tests/SQLInsight.IntegrationTests/CollectorTests.cs b/tests/SQLInsight.IntegrationTests/CollectorTests.cs index 0c0a737..2770739 100644 --- a/tests/SQLInsight.IntegrationTests/CollectorTests.cs +++ b/tests/SQLInsight.IntegrationTests/CollectorTests.cs @@ -103,6 +103,8 @@ public class CollectorTests(SqlServerFixture sql) var pc2 = await session.CollectAsync(new PerformanceCounterCollector(), pc1); Assert.Contains(io1.Snapshot.Files, f => f.DatabaseName == "master" && f.FileType == "LOG"); + // SQL Server 2022 has sys.dm_os_volume_stats: every file knows its drive (a mount point on Linux). + Assert.All(io1.Snapshot.Files, f => Assert.False(string.IsNullOrWhiteSpace(f.Volume))); Assert.NotNull(io2.Delta); Assert.NotNull(pc1.Snapshot.PageLifeExpectancySeconds); Assert.NotNull(pc1.Snapshot.MemoryGrantsPending); diff --git a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs index 778552c..19348b8 100644 --- a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs +++ b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs @@ -146,6 +146,7 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime var html = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from, to)}"); Assert.Contains("SELECT * FROM SALESLINE", html, StringComparison.Ordinal); Assert.Contains("LCK_M_X", html, StringComparison.Ordinal); + Assert.Contains("Diskhastighed pr. drev", html, StringComparison.Ordinal); Assert.Contains("class=\"unusual\"", html, StringComparison.Ordinal); Assert.Contains("value=\"" + TimeWindow.ToInput(from, Copenhagen) + "\"", html, StringComparison.Ordinal); @@ -163,6 +164,51 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime Assert.StartsWith("%PDF", System.Text.Encoding.ASCII.GetString(await pdf.Content.ReadAsByteArrayAsync())[..4], StringComparison.Ordinal); } + [Fact] + public async Task Disk_speed_is_summed_per_drive_and_kept_hourly() + { + var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1); + var from = to.AddHours(-1); + for (var at = from.AddMinutes(5); at <= to; at = at.AddMinutes(5)) + { + await _repository.SaveCollectedAsync(_instanceId, "file-io", new CumulativeSample( + new FileIoSnapshot { CapturedAt = at, SqlServerStartTime = DateTime.UtcNow.AddDays(-1), Files = [] }, + new FileIoDelta + { + From = at.AddMinutes(-5), + To = at, + Files = + [ + IoFile("AX30", 1, "ROWS", @"D:\AX30.mdf", reads: 3000, readStall: 90_000, bytesRead: 300 * 1048576L), + IoFile("AX30", 2, "LOG", @"L:\AX30_log.ldf", writes: 3000, writeStall: 3_000, bytesWritten: 30 * 1048576L), + ], + })); + } + + await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow); + var raw = await _repository.GetDiskActivityAsync(_instanceId, from, to); + var hourly = await _repository.GetDiskActivityHourlyAsync(_instanceId, from, to); + var analysis = await TimeWindowAnalysis.LoadAsync(_repository, (await _repository.GetInstanceAsync(@"AX01\AX30"))!, from, to, DateTimeOffset.UtcNow); + + var data = raw.Single(d => d.Volume == @"D:\"); + Assert.Equal(1, data.DataFiles); + Assert.Equal(30, data.AverageReadMs); + Assert.Equal(10, data.ReadIops); // 3000 reads per 300 s + Assert.Equal(1, data.ReadMbPerSecond); // 300 MB per 300 s + Assert.Equal(1, raw.Single(d => d.Volume == @"L:\").AverageWriteMs); + // The sample on the hour belongs to the next hour (as for waits): 11 samples of 300 s in this one. + Assert.Equal(11 * 300, hourly.Single(d => d.Volume == @"D:\").Seconds); + Assert.True(analysis.Disks.Single(d => d.Window.Volume == @"D:\").IsSlow); + Assert.False(analysis.Disks.Single(d => d.Window.Volume == @"L:\").IsUnusual); + } + + private static FileIoStat IoFile(string db, int id, string type, string path, long reads = 0, long readStall = 0, long bytesRead = 0, + long writes = 0, long writeStall = 0, long bytesWritten = 0) => new() + { + DatabaseName = db, FileId = id, FileType = type, PhysicalName = path, Reads = reads, ReadStallMs = readStall, BytesRead = bytesRead, + Writes = writes, WriteStallMs = writeStall, BytesWritten = bytesWritten, + }; + /// A busy hour: high CPU, lock waits and one heavy statement, against quiet same hours four weeks back. private async Task SeedWindowAsync(DateTimeOffset from, DateTimeOffset to) { diff --git a/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs b/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs index 1af6072..6db7ad7 100644 --- a/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs @@ -96,6 +96,22 @@ public class PerformanceRuleTests } } + [Fact] + public void Slow_data_files_on_one_drive_give_one_finding_for_the_drive() + { + var sales = File("ROWS", reads: 3000, readStallMs: 90_000); // 30 ms + var stock = File("ROWS", reads: 2000, readStallMs: 40_000) with { DatabaseName = "Stock", FileId = 1, PhysicalName = @"D:\Stock.mdf" }; + var fast = File("ROWS", reads: 5000, readStallMs: 5_000) with { DatabaseName = "Fast", PhysicalName = @"E:\Fast.mdf" }; + + var result = Run(new DataFileReadLatencyRule(), With(Io(sales, stock, fast))); + + var finding = Assert.Single(result.Findings); + Assert.Equal(@"volume: D:\ data", finding.Subject); + Assert.Contains(@"D:\ (datafiler): 26,0 ms", finding.Title, StringComparison.Ordinal); // 130 s over 5000 reads + Assert.Contains(finding.Evidence, e => e.Name == "Langsomste filer" && e.Value.StartsWith("Sales 30,0 ms", StringComparison.Ordinal)); + Assert.Contains(finding.Evidence, e => e.Name == "Læse-IOPS"); + } + private static CpuUtilization Cpu(int sql, int idle, int minutes = 15, string platform = "Windows") => new() { CapturedAt = Now,