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