- 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>
143 lines
5.9 KiB
C#
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 };
|
|
}
|
|
}
|