sys.dm_db_index_usage_stats also starts over when a database is taken offline, closed by AUTO_CLOSE or (2012/2014) an index is rebuilt. The delta was then clamped to 0, so the usage since the reset was lost from the daily history and the tracked totals. A lower counter now counts as new usage. IndexInfo.Key also used the literal text "001f" as its separator instead of the unit separator character. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
157 lines
6.9 KiB
C#
157 lines
6.9 KiB
C#
namespace SQLInsight.Core.Data;
|
||
|
||
/// <summary>
|
||
/// Indexes, their usage, missing-index suggestions and statistics per user database.
|
||
/// Usage counters reset when SQL Server restarts, so <see cref="SqlServerStartTime"/> says how much history they hold.
|
||
/// </summary>
|
||
public sealed record IndexSnapshot
|
||
{
|
||
public required DateTimeOffset CapturedAt { get; init; }
|
||
public required DateTime SqlServerStartTime { get; init; }
|
||
public required IReadOnlyList<IndexDatabase> Databases { get; init; }
|
||
|
||
public TimeSpan Uptime => CapturedAt.UtcDateTime - SqlServerStartTime;
|
||
|
||
/// <summary>Reads and writes per index since the previous collection, for the daily history.</summary>
|
||
public IReadOnlyList<IndexUsageDelta> UsageDeltas { get; init; } = [];
|
||
|
||
/// <summary>How long the usage of an index has been followed: across restarts, unlike <see cref="Uptime"/>.</summary>
|
||
public TimeSpan TrackedFor(IndexInfo index)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(index);
|
||
return index.TrackedSince is { } since ? CapturedAt - since : Uptime;
|
||
}
|
||
}
|
||
|
||
public sealed record IndexDatabase
|
||
{
|
||
public required string Name { get; init; }
|
||
public IReadOnlyList<IndexInfo> Indexes { get; init; } = [];
|
||
public IReadOnlyList<MissingIndex> MissingIndexes { get; init; } = [];
|
||
public IReadOnlyList<StatisticInfo> Statistics { get; init; } = [];
|
||
public string? Error { get; init; }
|
||
}
|
||
|
||
public sealed record IndexInfo
|
||
{
|
||
public required string Schema { get; init; }
|
||
public required string Table { get; init; }
|
||
public required int IndexId { get; init; }
|
||
|
||
/// <summary><see langword="null"/> for a heap.</summary>
|
||
public string? Name { get; init; }
|
||
|
||
/// <summary>HEAP, CLUSTERED, NONCLUSTERED, COLUMNSTORE …</summary>
|
||
public required string Type { get; init; }
|
||
|
||
public bool IsUnique { get; init; }
|
||
public bool IsPrimaryKey { get; init; }
|
||
public bool IsUniqueConstraint { get; init; }
|
||
public bool IsDisabled { get; init; }
|
||
public string? Filter { get; init; }
|
||
public IReadOnlyList<string> KeyColumns { get; init; } = [];
|
||
public IReadOnlyList<string> IncludedColumns { get; init; } = [];
|
||
public long Rows { get; init; }
|
||
public long UsedPages { get; init; }
|
||
public long UserSeeks { get; init; }
|
||
public long UserScans { get; init; }
|
||
public long UserLookups { get; init; }
|
||
public long UserUpdates { get; init; }
|
||
|
||
/// <summary>Heaps only: rows read through a forwarding pointer.</summary>
|
||
public long ForwardedFetches { get; init; }
|
||
|
||
/// <summary>Reads summed across restarts of SQL Server since <see cref="TrackedSince"/> (index usage is reset at each restart).</summary>
|
||
public long TrackedReads { get; init; }
|
||
|
||
/// <summary>Writes summed the same way.</summary>
|
||
public long TrackedWrites { get; init; }
|
||
|
||
/// <summary>Since when SQLInsight has followed the usage (the restart before the first collection).</summary>
|
||
public DateTimeOffset? TrackedSince { get; init; }
|
||
|
||
/// <summary>Identifies the index within its database across collections (unit separator between the parts).</summary>
|
||
public string Key => $"{Schema}\u001f{Table}\u001f{IndexId}";
|
||
|
||
public long Reads => UserSeeks + UserScans + UserLookups;
|
||
public double SizeMb => UsedPages * 8.0 / 1024;
|
||
public string QualifiedTable => $"{Schema}.{Table}";
|
||
}
|
||
|
||
public sealed record MissingIndex
|
||
{
|
||
/// <summary>[db].[schema].[table] as SQL Server reports it.</summary>
|
||
public required string Table { get; init; }
|
||
|
||
public string? EqualityColumns { get; init; }
|
||
public string? InequalityColumns { get; init; }
|
||
public string? IncludedColumns { get; init; }
|
||
public required long Seeks { get; init; }
|
||
public required long Scans { get; init; }
|
||
public required double AverageTotalUserCost { get; init; }
|
||
|
||
/// <summary>Estimated improvement in percent.</summary>
|
||
public required double AverageUserImpact { get; init; }
|
||
|
||
public DateTime? LastUserSeek { get; init; }
|
||
|
||
/// <summary>The usual ranking measure: cost × impact × uses.</summary>
|
||
public double ImprovementMeasure => AverageTotalUserCost * (AverageUserImpact / 100) * (Seeks + Scans);
|
||
}
|
||
|
||
public sealed record StatisticInfo
|
||
{
|
||
public required string Schema { get; init; }
|
||
public required string Table { get; init; }
|
||
public required string Name { get; init; }
|
||
public DateTime? LastUpdated { get; init; }
|
||
public long Rows { get; init; }
|
||
|
||
/// <summary>Row modifications since the last update (sysindexes.rowmodctr; approximate).</summary>
|
||
public long Modifications { get; init; }
|
||
}
|
||
|
||
/// <summary>An index's reads and writes since the previous collection (after a restart: since the restart).</summary>
|
||
public sealed record IndexUsageDelta(string Database, string Schema, string Table, string? Index, long Reads, long Writes);
|
||
|
||
public static class IndexUsageTracking
|
||
{
|
||
/// <summary>
|
||
/// Adds this collection's usage to the totals carried over from the previous one. After a restart the counters
|
||
/// started from zero again, so all of the current usage is new.
|
||
/// </summary>
|
||
public static IndexSnapshot Advance(IndexSnapshot? previous, IndexSnapshot current)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(current);
|
||
var restarted = previous is null || previous.SqlServerStartTime != current.SqlServerStartTime;
|
||
var start = new DateTimeOffset(DateTime.SpecifyKind(current.SqlServerStartTime, DateTimeKind.Utc));
|
||
var before = (previous?.Databases ?? [])
|
||
.SelectMany(d => d.Indexes.Select(i => (Key: d.Name + "\u001f" + i.Key, Index: i)))
|
||
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(g => g.Key, g => g.First().Index, StringComparer.OrdinalIgnoreCase);
|
||
var deltas = new List<IndexUsageDelta>();
|
||
var databases = current.Databases.Select(d => d with
|
||
{
|
||
Indexes = d.Indexes.Select(i =>
|
||
{
|
||
before.TryGetValue(d.Name + "\u001f" + i.Key, out var was);
|
||
// A counter lower than last time was reset without a restart (database taken offline or closed by
|
||
// AUTO_CLOSE, index rebuilt on 2012/2014): what it holds now is all new.
|
||
var reads = restarted || was is null || i.Reads < was.Reads ? i.Reads : i.Reads - was.Reads;
|
||
var writes = restarted || was is null || i.UserUpdates < was.UserUpdates ? i.UserUpdates : i.UserUpdates - was.UserUpdates;
|
||
if (reads > 0 || writes > 0)
|
||
{
|
||
deltas.Add(new IndexUsageDelta(d.Name, i.Schema, i.Table, i.Name, reads, writes));
|
||
}
|
||
|
||
return i with
|
||
{
|
||
TrackedReads = (was?.TrackedReads ?? 0) + reads,
|
||
TrackedWrites = (was?.TrackedWrites ?? 0) + writes,
|
||
TrackedSince = was?.TrackedSince ?? start,
|
||
};
|
||
}).ToList(),
|
||
}).ToList();
|
||
return current with { Databases = databases, UsageDeltas = deltas };
|
||
}
|
||
}
|