sqlinsight-pro/src/SQLInsight.Core/Data/FileIo.cs
Søren Eggert Lundsteen Olsen ac13e3d826 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) <noreply@anthropic.com>
2026-10-02 08:03:11 +02:00

143 lines
5.9 KiB
C#

namespace SQLInsight.Core.Data;
/// <summary>
/// Cumulative I/O per database file (<c>sys.dm_io_virtual_file_stats</c>).
/// </summary>
public sealed record FileIoSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList<FileIoStat> Files { get; init; }
}
public sealed record FileIoStat
{
public required string DatabaseName { get; init; }
public required int FileId { get; init; }
/// <summary>ROWS or LOG.</summary>
public required string FileType { get; init; }
public required string PhysicalName { get; init; }
/// <summary>
/// The volume (drive or mount point) the file lives on, e.g. <c>D:\</c>. From <c>sys.dm_os_volume_stats</c> where
/// the instance has it, otherwise derived from the path (<see cref="DiskVolumes.FromPhysicalName"/>).
/// </summary>
public string? Volume { get; init; }
public required long Reads { get; init; }
public required long BytesRead { get; init; }
public required long ReadStallMs { get; init; }
public required long Writes { get; init; }
public required long BytesWritten { get; init; }
public required long WriteStallMs { get; init; }
public double? AverageReadLatencyMs => Reads == 0 ? null : (double)ReadStallMs / Reads;
public double? AverageWriteLatencyMs => Writes == 0 ? null : (double)WriteStallMs / Writes;
}
/// <summary>
/// Which drive a database file is on, when SQL Server cannot say (before 2008 R2 SP1 there is no
/// <c>sys.dm_os_volume_stats</c>).
/// </summary>
public static class DiskVolumes
{
/// <summary>
/// <c>D:\Data\x.mdf</c> → <c>D:\</c>; <c>\\nas\sql\x.mdf</c> → <c>\\nas\sql\</c>; a Linux path → <c>/</c>. A mount point
/// under a drive letter cannot be told apart from the drive itself this way.
/// </summary>
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;
}
/// <summary>The file's volume as reported, or derived from its path.</summary>
public static string Of(FileIoStat file)
{
ArgumentNullException.ThrowIfNull(file);
return string.IsNullOrWhiteSpace(file.Volume) ? FromPhysicalName(file.PhysicalName) : file.Volume;
}
}
/// <summary>
/// I/O on one drive over an interval: the sum of its database files. Latency is weighted by the number of I/Os.
/// </summary>
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;
/// <summary>Groups the files of one interval by drive.</summary>
public static IReadOnlyList<DiskActivity> ByVolume(IEnumerable<FileIoStat> 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();
}
}
/// <summary>
/// I/O per file between two snapshots.
/// </summary>
public sealed record FileIoDelta
{
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public required IReadOnlyList<FileIoStat> Files { get; init; }
public static FileIoDelta? Compute(FileIoSnapshot? previous, FileIoSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous is null || previous.SqlServerStartTime != current.SqlServerStartTime || current.CapturedAt <= previous.CapturedAt)
{
return null;
}
var before = previous.Files.ToDictionary(f => (f.DatabaseName, f.FileId));
var files = new List<FileIoStat>();
foreach (var now in current.Files)
{
// A database restored or attached since the last snapshot starts from zero again; skip it this time.
if (!before.TryGetValue((now.DatabaseName, now.FileId), out var then) || now.Reads < then.Reads || now.Writes < then.Writes)
{
continue;
}
files.Add(now with
{
Reads = now.Reads - then.Reads,
BytesRead = now.BytesRead - then.BytesRead,
ReadStallMs = now.ReadStallMs - then.ReadStallMs,
Writes = now.Writes - then.Writes,
BytesWritten = now.BytesWritten - then.BytesWritten,
WriteStallMs = now.WriteStallMs - then.WriteStallMs,
});
}
return new FileIoDelta { From = previous.CapturedAt, To = current.CapturedAt, Files = files };
}
}