namespace SQLInsight.Core.Data;
///
/// Indexes, their usage, missing-index suggestions and statistics per user database.
/// Usage counters reset when SQL Server restarts, so says how much history they hold.
///
public sealed record IndexSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList Databases { get; init; }
public TimeSpan Uptime => CapturedAt.UtcDateTime - SqlServerStartTime;
/// Reads and writes per index since the previous collection, for the daily history.
public IReadOnlyList UsageDeltas { get; init; } = [];
/// How long the usage of an index has been followed: across restarts, unlike .
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 Indexes { get; init; } = [];
public IReadOnlyList MissingIndexes { get; init; } = [];
public IReadOnlyList 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; }
/// for a heap.
public string? Name { get; init; }
/// HEAP, CLUSTERED, NONCLUSTERED, COLUMNSTORE …
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 KeyColumns { get; init; } = [];
public IReadOnlyList 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; }
/// Heaps only: rows read through a forwarding pointer.
public long ForwardedFetches { get; init; }
/// Reads summed across restarts of SQL Server since (index usage is reset at each restart).
public long TrackedReads { get; init; }
/// Writes summed the same way.
public long TrackedWrites { get; init; }
/// Since when SQLInsight has followed the usage (the restart before the first collection).
public DateTimeOffset? TrackedSince { get; init; }
/// Identifies the index within its database across collections (unit separator between the parts).
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
{
/// [db].[schema].[table] as SQL Server reports it.
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; }
/// Estimated improvement in percent.
public required double AverageUserImpact { get; init; }
public DateTime? LastUserSeek { get; init; }
/// The usual ranking measure: cost × impact × uses.
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; }
/// Row modifications since the last update (sysindexes.rowmodctr; approximate).
public long Modifications { get; init; }
}
/// An index's reads and writes since the previous collection (after a restart: since the restart).
public sealed record IndexUsageDelta(string Database, string Schema, string Table, string? Index, long Reads, long Writes);
public static class IndexUsageTracking
{
///
/// 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.
///
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();
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 };
}
}