sqlinsight-pro/src/SQLInsight.Core/Data/Indexes.cs
Søren Eggert Lundsteen Olsen 4ceb64f2d5 Review 1.2: index usage after a counter reset without a restart
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>
2026-10-02 11:34:17 +02:00

157 lines
6.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}
}