diff --git a/Directory.Packages.props b/Directory.Packages.props index 5aedb6b..b99d1f5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/SQLInsight.slnx b/SQLInsight.slnx index 28b3969..7af94bf 100644 --- a/SQLInsight.slnx +++ b/SQLInsight.slnx @@ -6,6 +6,7 @@ + diff --git a/src/SQLInsight.Collectors/Performance/CpuCollector.cs b/src/SQLInsight.Collectors/Performance/CpuCollector.cs index 9a235af..b931703 100644 --- a/src/SQLInsight.Collectors/Performance/CpuCollector.cs +++ b/src/SQLInsight.Collectors/Performance/CpuCollector.cs @@ -33,10 +33,20 @@ public sealed class CpuCollector : Collector var samples = await Db.QueryAsync( context.Connection, Sql, - r => new CpuSample(new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(0), DateTimeKind.Utc)), r.Int(1), r.Int(2)), + r => new CpuSample(ToMinute(r.DateTime(0)), r.Int(1), r.Int(2)), context.CancellationToken).ConfigureAwait(false); samples.Reverse(); return new CpuUtilization { CapturedAt = context.Now, Samples = samples }; } + + /// + /// The time is derived from ms_ticks and can differ by a second between collections; the monitor writes once a + /// minute, so rounding to the minute gives each record a stable time. + /// + internal static DateTimeOffset ToMinute(DateTime utc) + { + var rounded = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, 0, DateTimeKind.Utc); + return new DateTimeOffset(utc.Second >= 30 ? rounded.AddMinutes(1) : rounded); + } } diff --git a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs new file mode 100644 index 0000000..832c146 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs @@ -0,0 +1,83 @@ +using Dapper; + +namespace SQLInsight.Repository; + +public sealed class RetentionOptions +{ + /// Raw samples (5-minute waits, per-minute CPU …). + public int RawDays { get; set; } = 14; + + /// Hourly aggregates, for long trends. + public int HourlyDays { get; set; } = 400; + + /// Blocking events and deadlock graphs. + public int EventDays { get; set; } = 90; + + /// Resolved findings. + public int ResolvedFindingDays { get; set; } = 365; +} + +public sealed partial class InsightRepository +{ + private const int DeleteBatchSize = 5000; + + /// + /// Aggregates recent raw samples to hourly values and deletes data past retention. Deletes run in small batches + /// so the repository's own transaction log never has to hold a huge delete. + /// + public async Task RunMaintenanceAsync(RetentionOptions options, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var utc = now.UtcDateTime; + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + + // Recompute the last two days of complete and current hours; idempotent, so it tolerates missed runs. + var from = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc).AddDays(-2); + await connection.ExecuteAsync(""" + MERGE insight.MetricHourly AS t + USING ( + SELECT InstanceId, Metric, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], + AVG(Value) AS AvgValue, MAX(Value) AS MaxValue, COUNT(*) AS Samples + FROM insight.MetricSamples WHERE SampledAt >= @from + GROUP BY InstanceId, Metric, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) + ) AS s ON t.InstanceId = s.InstanceId AND t.Metric = s.Metric AND t.[Hour] = s.[Hour] + WHEN MATCHED THEN UPDATE SET AvgValue = s.AvgValue, MaxValue = s.MaxValue, Samples = s.Samples + WHEN NOT MATCHED THEN INSERT (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES (s.InstanceId, s.Metric, s.[Hour], s.AvgValue, s.MaxValue, s.Samples); + + MERGE insight.WaitHourly AS t + USING ( + SELECT InstanceId, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], WaitType, + SUM(WaitingTasks) AS WaitingTasks, SUM(WaitTimeMs) AS WaitTimeMs, SUM(SignalWaitTimeMs) AS SignalWaitTimeMs + FROM insight.WaitSamples WHERE SampledAt >= @from + GROUP BY InstanceId, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0), WaitType + ) AS s ON t.InstanceId = s.InstanceId AND t.[Hour] = s.[Hour] AND t.WaitType = s.WaitType + WHEN MATCHED THEN UPDATE SET WaitingTasks = s.WaitingTasks, WaitTimeMs = s.WaitTimeMs, SignalWaitTimeMs = s.SignalWaitTimeMs + WHEN NOT MATCHED THEN INSERT (InstanceId, [Hour], WaitType, WaitingTasks, WaitTimeMs, SignalWaitTimeMs) VALUES (s.InstanceId, s.[Hour], s.WaitType, s.WaitingTasks, s.WaitTimeMs, s.SignalWaitTimeMs); + """, new { from }, commandTimeout: 600).ConfigureAwait(false); + + var raw = utc.AddDays(-options.RawDays); + var hourly = utc.AddDays(-options.HourlyDays); + var events = utc.AddDays(-options.EventDays); + var resolved = utc.AddDays(-options.ResolvedFindingDays); + + await DeleteInBatchesAsync(connection, "insight.MetricSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false); + } + + private static async Task DeleteInBatchesAsync(Microsoft.Data.SqlClient.SqlConnection connection, string table, string predicate, DateTime cutoff) + { + int deleted; + do + { + deleted = await connection.ExecuteAsync($"DELETE TOP ({DeleteBatchSize}) FROM {table} WHERE {predicate};", new { cutoff }, commandTimeout: 300) + .ConfigureAwait(false); + } + while (deleted == DeleteBatchSize); + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.Read.cs b/src/SQLInsight.Repository/InsightRepository.Read.cs new file mode 100644 index 0000000..3a3623c --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Read.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using Dapper; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Repository; + +public sealed record InstanceOverview +{ + public int InstanceId { get; init; } + public string Name { get; init; } = string.Empty; + public string? ServerName { get; init; } + public string? ProductVersion { get; init; } + public string? ProductLevel { get; init; } + public string? ProductUpdateLevel { get; init; } + public string? Edition { get; init; } + public string? ConnectionProvider { get; init; } + public bool UsedLegacyFallback { get; init; } + public DateTime? LastSeenAt { get; init; } + public DateTime? LastErrorAt { get; init; } + public string? LastError { get; init; } + public int Critical { get; init; } + public int High { get; init; } + public int Medium { get; init; } + public int Low { get; init; } + public int Info { get; init; } + public int NotMeasurable { get; init; } + + /// The most recent contact failed. + public bool IsFailing => LastErrorAt is { } e && (LastSeenAt is null || e > LastSeenAt); + + public Severity? WorstSeverity => + Critical > 0 ? Severity.Critical : High > 0 ? Severity.High : Medium > 0 ? Severity.Medium : Low > 0 ? Severity.Low : Info > 0 ? Severity.Info : null; +} + +public sealed record StoredFinding +{ + public long FindingId { get; init; } + public int InstanceId { get; init; } + public string InstanceName { get; init; } = string.Empty; + public string RuleId { get; init; } = string.Empty; + public string Subject { get; init; } = string.Empty; + public Severity Severity { get; init; } + public string Title { get; init; } = string.Empty; + public string Explanation { get; init; } = string.Empty; + public string Recommendation { get; init; } = string.Empty; + public IReadOnlyList Evidence { get; init; } = []; + public IReadOnlyList References { get; init; } = []; + public string? FixScript { get; init; } + public DateTime FirstSeenAt { get; init; } + public DateTime LastSeenAt { get; init; } + public DateTime? ResolvedAt { get; init; } +} + +public sealed record StoredRuleState(string RuleId, string RuleTitle, RuleOutcome Outcome, string? Reason, DateTime EvaluatedAt); + +public sealed record MetricPoint(DateTime At, double Value); + +public sealed record WaitTotal(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs) +{ + public WaitCategory Category => WaitCatalog.Categorize(WaitType); +} + +public sealed record FileLatency(string DatabaseName, int FileId, string FileType, long Reads, long ReadStallMs, long Writes, long WriteStallMs) +{ + public double? AverageReadMs => Reads == 0 ? null : (double)ReadStallMs / Reads; + public double? AverageWriteMs => Writes == 0 ? null : (double)WriteStallMs / Writes; +} + +public sealed record StoredDeadlock(long DeadlockId, DateTime OccurredAt, IReadOnlyList Processes, string GraphXml); + +public sealed record StoredBlocking(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, IReadOnlyList Sessions); + +public sealed partial class InsightRepository +{ + public async Task> GetInstancesAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.ConnectionProvider, + i.UsedLegacyFallback, i.LastSeenAt, i.LastErrorAt, i.LastError, + ISNULL(f.Critical, 0) AS Critical, ISNULL(f.High, 0) AS High, ISNULL(f.Medium, 0) AS Medium, ISNULL(f.Low, 0) AS Low, ISNULL(f.Info, 0) AS Info, + ISNULL(r.NotMeasurable, 0) AS NotMeasurable + FROM insight.Instances i + OUTER APPLY (SELECT SUM(CASE WHEN Severity = 4 THEN 1 ELSE 0 END) AS Critical, SUM(CASE WHEN Severity = 3 THEN 1 ELSE 0 END) AS High, + SUM(CASE WHEN Severity = 2 THEN 1 ELSE 0 END) AS Medium, SUM(CASE WHEN Severity = 1 THEN 1 ELSE 0 END) AS Low, + SUM(CASE WHEN Severity = 0 THEN 1 ELSE 0 END) AS Info + FROM insight.Findings WHERE InstanceId = i.InstanceId AND ResolvedAt IS NULL) f + OUTER APPLY (SELECT COUNT(*) AS NotMeasurable FROM insight.RuleStates WHERE InstanceId = i.InstanceId AND Outcome IN (2, 3)) r + ORDER BY i.Name; + """).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task GetInstanceAsync(string name, CancellationToken cancellationToken = default) => + (await GetInstancesAsync(cancellationToken).ConfigureAwait(false)).FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + private const string FindingColumns = """ + f.FindingId, f.InstanceId, i.Name AS InstanceName, f.RuleId, f.Subject, f.Severity, f.Title, f.Explanation, f.Recommendation, + f.EvidenceJson, f.ReferencesJson, f.FixScript, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt + """; + + public async Task> GetActiveFindingsAsync(int? instanceId = null, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync($""" + SELECT {FindingColumns} + FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId + WHERE f.ResolvedAt IS NULL AND (@instanceId IS NULL OR f.InstanceId = @instanceId) + ORDER BY f.Severity DESC, f.FirstSeenAt; + """, new { instanceId }).ConfigureAwait(false); + return rows.Select(Map).ToList(); + } + + public async Task> GetRecentlyResolvedAsync(int instanceId, int days, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync($""" + SELECT TOP (50) {FindingColumns} + FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId + WHERE f.InstanceId = @instanceId AND f.ResolvedAt >= DATEADD(day, -@days, SYSUTCDATETIME()) + ORDER BY f.ResolvedAt DESC; + """, new { instanceId, days }).ConfigureAwait(false); + return rows.Select(Map).ToList(); + } + + public async Task GetFindingAsync(long findingId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var row = await connection.QuerySingleOrDefaultAsync($""" + SELECT {FindingColumns} + FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId + WHERE f.FindingId = @findingId; + """, new { findingId }).ConfigureAwait(false); + return row is null ? null : Map(row); + } + + public async Task> GetRuleStatesAsync(int instanceId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string RuleId, string RuleTitle, byte Outcome, string? Reason, DateTime EvaluatedAt)>( + "SELECT RuleId, RuleTitle, Outcome, Reason, EvaluatedAt FROM insight.RuleStates WHERE InstanceId = @instanceId ORDER BY RuleId;", + new { instanceId }).ConfigureAwait(false); + return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList(); + } + + /// A metric over time: raw samples within raw retention, hourly averages beyond. + public async Task> GetMetricAsync(int instanceId, string metric, DateTimeOffset from, int rawDays = 14, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var useHourly = from.UtcDateTime < DateTime.UtcNow.AddDays(-rawDays) || DateTime.UtcNow - from.UtcDateTime > TimeSpan.FromDays(2); + var sql = useHourly + ? "SELECT [Hour] AS At, AvgValue AS Value FROM insight.MetricHourly WHERE InstanceId = @instanceId AND Metric = @metric AND [Hour] >= @from ORDER BY [Hour];" + : "SELECT SampledAt AS At, Value FROM insight.MetricSamples WHERE InstanceId = @instanceId AND Metric = @metric AND SampledAt >= @from ORDER BY SampledAt;"; + var rows = await connection.QueryAsync(sql, new { instanceId, metric, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task> GetTopWaitsAsync(int instanceId, DateTimeOffset from, int top = 10, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT TOP (@top) WaitType, SUM(WaitingTasks) AS WaitingTasks, SUM(WaitTimeMs) AS WaitTimeMs, SUM(SignalWaitTimeMs) AS SignalWaitTimeMs + FROM insight.WaitSamples WHERE InstanceId = @instanceId AND SampledAt >= @from + GROUP BY WaitType ORDER BY SUM(WaitTimeMs) DESC; + """, new { instanceId, from = from.UtcDateTime, top }).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task> GetFileLatencyAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT DatabaseName, FileId, MAX(FileType) AS FileType, SUM(Reads) AS Reads, SUM(ReadStallMs) AS ReadStallMs, SUM(Writes) AS Writes, SUM(WriteStallMs) AS WriteStallMs + FROM insight.FileIoSamples WHERE InstanceId = @instanceId AND SampledAt >= @from + GROUP BY DatabaseName, FileId + ORDER BY SUM(ReadStallMs) + SUM(WriteStallMs) DESC; + """, new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task> GetDeadlocksAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)>(""" + SELECT TOP (200) DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks + WHERE InstanceId = @instanceId AND OccurredAt >= @from ORDER BY OccurredAt DESC; + """, new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.Select(r => new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize>(r.ProcessesJson, Json) ?? [], r.GraphXml)).ToList(); + } + + public async Task GetDeadlockAsync(long deadlockId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var row = await connection.QuerySingleOrDefaultAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)?>( + "SELECT DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks WHERE DeadlockId = @deadlockId;", new { deadlockId }).ConfigureAwait(false); + return row is { } r ? new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize>(r.ProcessesJson, Json) ?? [], r.GraphXml) : null; + } + + public async Task> GetBlockingAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, string SessionsJson)>(""" + SELECT TOP (200) SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson FROM insight.BlockingEvents + WHERE InstanceId = @instanceId AND SampledAt >= @from ORDER BY SampledAt DESC; + """, new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs, + JsonSerializer.Deserialize>(r.SessionsJson, Json) ?? [])).ToList(); + } + + public async Task GetSnapshotAsync(int instanceId, string kind, CancellationToken cancellationToken = default) + where T : class + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var json = await connection.QuerySingleOrDefaultAsync( + "SELECT Json FROM insight.Snapshots WHERE InstanceId = @instanceId AND Kind = @kind;", new { instanceId, kind }).ConfigureAwait(false); + return json is null ? null : JsonSerializer.Deserialize(json, Json); + } + + private static StoredFinding Map(FindingRow r) => new() + { + FindingId = r.FindingId, + InstanceId = r.InstanceId, + InstanceName = r.InstanceName, + RuleId = r.RuleId, + Subject = r.Subject, + Severity = (Severity)r.Severity, + Title = r.Title, + Explanation = r.Explanation, + Recommendation = r.Recommendation, + Evidence = JsonSerializer.Deserialize>(r.EvidenceJson, Json) ?? [], + References = JsonSerializer.Deserialize>(r.ReferencesJson, Json) ?? [], + FixScript = r.FixScript, + FirstSeenAt = r.FirstSeenAt, + LastSeenAt = r.LastSeenAt, + ResolvedAt = r.ResolvedAt, + }; + + private sealed record FindingRow + { + public long FindingId { get; init; } + public int InstanceId { get; init; } + public string InstanceName { get; init; } = string.Empty; + public string RuleId { get; init; } = string.Empty; + public string Subject { get; init; } = string.Empty; + public byte Severity { get; init; } + public string Title { get; init; } = string.Empty; + public string Explanation { get; init; } = string.Empty; + public string Recommendation { get; init; } = string.Empty; + public string EvidenceJson { get; init; } = "[]"; + public string ReferencesJson { get; init; } = "[]"; + public string? FixScript { get; init; } + public DateTime FirstSeenAt { get; init; } + public DateTime LastSeenAt { get; init; } + public DateTime? ResolvedAt { get; init; } + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.cs b/src/SQLInsight.Repository/InsightRepository.cs new file mode 100644 index 0000000..9b1e623 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.cs @@ -0,0 +1,324 @@ +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Repository; + +/// +/// The on-prem repository: what the agent has found and measured. Lives on a SQL Server instance separate from +/// the ones being monitored. +/// +public sealed partial class InsightRepository(string connectionString) +{ + // PascalCase: OPENJSON ... WITH matches property names case-sensitively. + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.General); + + public Task MigrateAsync(CancellationToken cancellationToken = default) => SchemaMigrator.MigrateAsync(connectionString, cancellationToken); + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + return connection; + } + + /// + /// UTC truncated to whole seconds, matching the datetime2(0) columns. Without truncation SQL Server rounds on + /// insert but compares at full precision, so "insert if not exists" would insert duplicates. + /// + internal static DateTime Utc(DateTimeOffset value) + { + var utc = value.UtcDateTime; + return new DateTime(utc.Ticks - (utc.Ticks % TimeSpan.TicksPerSecond), DateTimeKind.Utc); + } + + /// Registers a successful contact with an instance and returns its id. + public async Task UpsertInstanceAsync( + string name, InstanceInfo info, string provider, bool usedLegacyFallback, DateTimeOffset seenAt, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(info); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + MERGE insight.Instances WITH (HOLDLOCK) AS t + USING (SELECT @name AS Name) AS s ON t.Name = s.Name + WHEN MATCHED THEN UPDATE SET ServerName = @serverName, ProductVersion = @version, ProductLevel = @level, ProductUpdateLevel = @updateLevel, + Edition = @edition, EngineEdition = @engineEdition, ConnectionProvider = @provider, UsedLegacyFallback = @legacy, LastSeenAt = @seenAt + WHEN NOT MATCHED THEN INSERT (Name, ServerName, ProductVersion, ProductLevel, ProductUpdateLevel, Edition, EngineEdition, ConnectionProvider, UsedLegacyFallback, LastSeenAt) + VALUES (@name, @serverName, @version, @level, @updateLevel, @edition, @engineEdition, @provider, @legacy, @seenAt) + OUTPUT inserted.InstanceId; + """, new + { + name, + serverName = info.ServerName, + version = info.Version.ToString(), + level = info.ProductLevel, + updateLevel = info.ProductUpdateLevel, + edition = info.Edition, + engineEdition = (int)info.EngineEdition, + provider, + legacy = usedLegacyFallback, + seenAt = Utc(seenAt), + }).ConfigureAwait(false); + } + + /// Records that an instance could not be reached or checked. + public async Task RecordInstanceErrorAsync(string name, string error, DateTimeOffset at, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + MERGE insight.Instances WITH (HOLDLOCK) AS t + USING (SELECT @name AS Name) AS s ON t.Name = s.Name + WHEN MATCHED THEN UPDATE SET LastError = @error, LastErrorAt = @at + WHEN NOT MATCHED THEN INSERT (Name, LastError, LastErrorAt) VALUES (@name, @error, @at); + """, new { name, error, at = Utc(at) }).ConfigureAwait(false); + } + + /// + /// Stores the outcome of a rule evaluation and tracks findings: new ones start, existing ones are updated, and + /// ones no longer reported by a rule that ran are resolved. A rule that could not run leaves its findings as they were. + /// + public async Task SaveRuleResultsAsync(int instanceId, IReadOnlyList results, DateTimeOffset evaluatedAt, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(results); + var now = Utc(evaluatedAt); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + foreach (var result in results) + { + await connection.ExecuteAsync(""" + MERGE insight.RuleStates AS t + USING (SELECT @instanceId AS InstanceId, @ruleId AS RuleId) AS s ON t.InstanceId = s.InstanceId AND t.RuleId = s.RuleId + WHEN MATCHED THEN UPDATE SET RuleTitle = @title, Outcome = @outcome, Reason = @reason, EvaluatedAt = @now + WHEN NOT MATCHED THEN INSERT (InstanceId, RuleId, RuleTitle, Outcome, Reason, EvaluatedAt) VALUES (@instanceId, @ruleId, @title, @outcome, @reason, @now); + """, new { instanceId, ruleId = result.RuleId, title = result.RuleTitle, outcome = (byte)result.Outcome, reason = result.Reason, now }, transaction).ConfigureAwait(false); + + if (result.Outcome is not (RuleOutcome.Passed or RuleOutcome.Findings)) + { + continue; + } + + var current = result.Findings.Select(f => f.Subject ?? string.Empty).ToList(); + await connection.ExecuteAsync(""" + UPDATE insight.Findings SET ResolvedAt = @now + WHERE InstanceId = @instanceId AND RuleId = @ruleId AND ResolvedAt IS NULL + AND Subject NOT IN (SELECT value FROM OPENJSON(@current)); + """, new { instanceId, ruleId = result.RuleId, now, current = JsonSerializer.Serialize(current, Json) }, transaction).ConfigureAwait(false); + + foreach (var finding in result.Findings) + { + await connection.ExecuteAsync(""" + UPDATE insight.Findings + SET Severity = @severity, Title = @title, Explanation = @explanation, Recommendation = @recommendation, + EvidenceJson = @evidence, ReferencesJson = @references, FixScript = @fixScript, LastSeenAt = @now + WHERE InstanceId = @instanceId AND RuleId = @ruleId AND Subject = @subject AND ResolvedAt IS NULL; + + IF @@ROWCOUNT = 0 + INSERT insight.Findings (InstanceId, RuleId, Subject, Severity, Title, Explanation, Recommendation, EvidenceJson, ReferencesJson, FixScript, FirstSeenAt, LastSeenAt) + VALUES (@instanceId, @ruleId, @subject, @severity, @title, @explanation, @recommendation, @evidence, @references, @fixScript, @now, @now); + """, new + { + instanceId, + ruleId = finding.RuleId, + subject = finding.Subject ?? string.Empty, + severity = (byte)finding.Severity, + title = finding.Title, + explanation = finding.Explanation, + recommendation = finding.Recommendation, + evidence = JsonSerializer.Serialize(finding.Evidence, Json), + references = JsonSerializer.Serialize(finding.References, Json), + fixScript = finding.FixScript, + now, + }, transaction).ConfigureAwait(false); + } + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Stores collected data: time series for trends, events (blocking, deadlocks) and the latest snapshot of + /// everything else for display. + /// + public async Task SaveCollectedAsync(int instanceId, string kind, object data, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(data); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + switch (data) + { + case CumulativeSample { Delta: { } waits }: + await SaveWaitsAsync(connection, instanceId, waits).ConfigureAwait(false); + return; + case CumulativeSample: + return; + case CumulativeSample { Delta: { } io }: + await SaveFileIoAsync(connection, instanceId, io).ConfigureAwait(false); + return; + case CumulativeSample: + return; + case CumulativeSample counters: + await SaveCountersAsync(connection, instanceId, counters).ConfigureAwait(false); + return; + case CpuUtilization cpu: + await SaveCpuAsync(connection, instanceId, cpu).ConfigureAwait(false); + return; + case BlockingSnapshot blocking: + await SaveBlockingAsync(connection, instanceId, blocking).ConfigureAwait(false); + return; + case DeadlockSnapshot deadlocks: + await SaveDeadlocksAsync(connection, instanceId, deadlocks).ConfigureAwait(false); + return; + default: + await connection.ExecuteAsync(""" + MERGE insight.Snapshots AS t + USING (SELECT @instanceId AS InstanceId, @kind AS Kind) AS s ON t.InstanceId = s.InstanceId AND t.Kind = s.Kind + WHEN MATCHED THEN UPDATE SET CapturedAt = SYSUTCDATETIME(), Json = @json + WHEN NOT MATCHED THEN INSERT (InstanceId, Kind, CapturedAt, Json) VALUES (@instanceId, @kind, SYSUTCDATETIME(), @json); + """, new { instanceId, kind, json = JsonSerializer.Serialize(data, data.GetType(), Json) }).ConfigureAwait(false); + return; + } + } + + private static Task SaveWaitsAsync(SqlConnection connection, int instanceId, WaitStatsDelta delta) => + connection.ExecuteAsync(""" + INSERT insight.WaitSamples (InstanceId, SampledAt, WaitType, IntervalSeconds, WaitingTasks, WaitTimeMs, SignalWaitTimeMs) + SELECT @instanceId, @at, WaitType, @interval, WaitingTasks, WaitTimeMs, SignalWaitTimeMs + FROM OPENJSON(@json) WITH (WaitType nvarchar(60), WaitingTasks bigint, WaitTimeMs bigint, SignalWaitTimeMs bigint) j + WHERE NOT EXISTS (SELECT 1 FROM insight.WaitSamples w WHERE w.InstanceId = @instanceId AND w.SampledAt = @at AND w.WaitType = j.WaitType); + """, new + { + instanceId, + at = Utc(delta.To), + interval = (int)delta.Interval.TotalSeconds, + json = JsonSerializer.Serialize(delta.Waits, Json), + }); + + private static Task SaveFileIoAsync(SqlConnection connection, int instanceId, FileIoDelta delta) => + connection.ExecuteAsync(""" + INSERT insight.FileIoSamples (InstanceId, SampledAt, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten) + SELECT @instanceId, @at, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten + FROM OPENJSON(@json) WITH (DatabaseName nvarchar(128), FileId int, FileType varchar(10), Reads bigint, ReadStallMs bigint, + Writes bigint, WriteStallMs bigint, BytesRead bigint, BytesWritten bigint) j + WHERE NOT EXISTS (SELECT 1 FROM insight.FileIoSamples f WHERE f.InstanceId = @instanceId AND f.SampledAt = @at AND f.DatabaseName = j.DatabaseName AND f.FileId = j.FileId); + """, new { instanceId, at = Utc(delta.To), json = JsonSerializer.Serialize(delta.Files, Json) }); + + private static Task InsertMetricsAsync(SqlConnection connection, int instanceId, IEnumerable<(string Metric, DateTimeOffset At, double Value)> metrics) => + connection.ExecuteAsync(""" + INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) + SELECT @instanceId, Metric, SampledAt, Value + FROM OPENJSON(@json) WITH (Metric varchar(60), SampledAt datetime2(0), Value float) j + WHERE NOT EXISTS (SELECT 1 FROM insight.MetricSamples m WHERE m.InstanceId = @instanceId AND m.Metric = j.Metric AND m.SampledAt = j.SampledAt); + """, new + { + instanceId, + json = JsonSerializer.Serialize(metrics.Select(m => new { m.Metric, SampledAt = m.At.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss", System.Globalization.CultureInfo.InvariantCulture), m.Value }), Json), + }); + + private static Task SaveCountersAsync(SqlConnection connection, int instanceId, CumulativeSample counters) + { + var at = counters.Snapshot.CapturedAt; + var metrics = new List<(string, DateTimeOffset, double)>(); + void Add(string metric, double? value) + { + if (value is { } v) + { + metrics.Add((metric, at, v)); + } + } + + Add(Metrics.PageLifeExpectancy, counters.Snapshot.PageLifeExpectancySeconds); + Add(Metrics.MemoryGrantsPending, counters.Snapshot.MemoryGrantsPending); + Add(Metrics.UserConnections, counters.Snapshot.UserConnections); + Add(Metrics.ProcessesBlocked, counters.Snapshot.ProcessesBlocked); + Add(Metrics.BatchRequestsPerSecond, counters.Delta?.BatchRequestsPerSecond); + Add(Metrics.CompilationsPerSecond, counters.Delta?.CompilationsPerSecond); + return InsertMetricsAsync(connection, instanceId, metrics); + } + + private static Task SaveCpuAsync(SqlConnection connection, int instanceId, CpuUtilization cpu) => + InsertMetricsAsync(connection, instanceId, cpu.Samples.SelectMany(s => new[] + { + (Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent), + (Metrics.CpuOther, s.Time, (double)s.OtherProcessesPercent), + })); + + /// Blocking shorter than this is normal and not stored. + public const long BlockingThresholdMs = 5000; + + private static async Task SaveBlockingAsync(SqlConnection connection, int instanceId, BlockingSnapshot blocking) + { + foreach (var head in blocking.HeadBlockers) + { + var chain = Chain(blocking, head.SessionId); + var maxWait = chain.Max(s => s.WaitTimeMs ?? 0); + if (maxWait < BlockingThresholdMs) + { + continue; + } + + await connection.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM insight.BlockingEvents WHERE InstanceId = @instanceId AND SampledAt = @at AND HeadSessionId = @head) + INSERT insight.BlockingEvents (InstanceId, SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson) + VALUES (@instanceId, @at, @head, @count, @maxWait, @json); + """, new + { + instanceId, + at = Utc(blocking.CapturedAt), + head = head.SessionId, + count = chain.Count, + maxWait, + json = JsonSerializer.Serialize(chain.Prepend(head), Json), + }).ConfigureAwait(false); + } + } + + private static List Chain(BlockingSnapshot snapshot, int head) + { + var result = new List(); + var queue = new Queue([head]); + var seen = new HashSet { head }; + while (queue.Count > 0) + { + foreach (var s in snapshot.BlockedBy(queue.Dequeue()).Where(s => seen.Add(s.SessionId))) + { + result.Add(s); + queue.Enqueue(s.SessionId); + } + } + + return result; + } + + private static async Task SaveDeadlocksAsync(SqlConnection connection, int instanceId, DeadlockSnapshot snapshot) + { + foreach (var deadlock in snapshot.NewDeadlocks) + { + await connection.ExecuteAsync(""" + INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) + VALUES (@instanceId, @at, @processes, @graph); + """, new + { + instanceId, + at = Utc(deadlock.Time), + processes = JsonSerializer.Serialize(deadlock.Processes, Json), + graph = deadlock.GraphXml, + }).ConfigureAwait(false); + } + } +} + +public static class Metrics +{ + public const string CpuSqlServer = "cpu.sqlserver"; + public const string CpuOther = "cpu.other"; + public const string PageLifeExpectancy = "memory.ple"; + public const string MemoryGrantsPending = "memory.grants_pending"; + public const string UserConnections = "connections.user"; + public const string ProcessesBlocked = "blocking.processes"; + public const string BatchRequestsPerSecond = "workload.batch_requests"; + public const string CompilationsPerSecond = "workload.compilations"; +} diff --git a/src/SQLInsight.Repository/Migrations/0001_initial.sql b/src/SQLInsight.Repository/Migrations/0001_initial.sql new file mode 100644 index 0000000..2c43dd5 --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0001_initial.sql @@ -0,0 +1,148 @@ +/* SQLInsight Pro repository, version 1. All times are UTC (datetime2). + The repository uses OPENJSON, so it needs SQL Server 2016 or later with compatibility level 130+. + (The monitored instances can be any version from 2000.) */ +IF (SELECT compatibility_level FROM sys.databases WHERE name = DB_NAME()) < 130 + RAISERROR (N'SQLInsight-repositoriet kræver SQL Server 2016+ og compatibility level 130 eller højere.', 16, 1); +IF SCHEMA_ID(N'insight') IS NULL EXEC (N'CREATE SCHEMA insight AUTHORIZATION dbo;'); + +CREATE TABLE insight.Instances +( + InstanceId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Instances PRIMARY KEY, + Name nvarchar(128) NOT NULL CONSTRAINT UQ_Instances_Name UNIQUE, + ServerName nvarchar(256) NULL, + ProductVersion varchar(32) NULL, + ProductLevel nvarchar(32) NULL, + ProductUpdateLevel nvarchar(32) NULL, + Edition nvarchar(128) NULL, + EngineEdition int NULL, + ConnectionProvider varchar(20) NULL, + UsedLegacyFallback bit NOT NULL CONSTRAINT DF_Instances_Legacy DEFAULT (0), + LastSeenAt datetime2(0) NULL, + LastErrorAt datetime2(0) NULL, + LastError nvarchar(max) NULL +); + +/* One row per finding occurrence. An active finding has ResolvedAt NULL; when it disappears it is resolved, + and if it comes back a new row starts, so history shows each period. */ +CREATE TABLE insight.Findings +( + FindingId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Findings PRIMARY KEY, + InstanceId int NOT NULL CONSTRAINT FK_Findings_Instances REFERENCES insight.Instances (InstanceId), + RuleId varchar(40) NOT NULL, + Subject nvarchar(400) NOT NULL, + Severity tinyint NOT NULL, + Title nvarchar(1000) NOT NULL, + Explanation nvarchar(max) NOT NULL, + Recommendation nvarchar(max) NOT NULL, + EvidenceJson nvarchar(max) NOT NULL, + ReferencesJson nvarchar(max) NOT NULL, + FixScript nvarchar(max) NULL, + FirstSeenAt datetime2(0) NOT NULL, + LastSeenAt datetime2(0) NOT NULL, + ResolvedAt datetime2(0) NULL +); +CREATE UNIQUE INDEX UX_Findings_Active ON insight.Findings (InstanceId, RuleId, Subject) WHERE ResolvedAt IS NULL; +CREATE INDEX IX_Findings_Resolved ON insight.Findings (ResolvedAt) WHERE ResolvedAt IS NOT NULL; + +/* Latest outcome per rule, so the UI can show what could not be measured and why. */ +CREATE TABLE insight.RuleStates +( + InstanceId int NOT NULL CONSTRAINT FK_RuleStates_Instances REFERENCES insight.Instances (InstanceId), + RuleId varchar(40) NOT NULL, + RuleTitle nvarchar(400) NOT NULL, + Outcome tinyint NOT NULL, + Reason nvarchar(max) NULL, + EvaluatedAt datetime2(0) NOT NULL, + CONSTRAINT PK_RuleStates PRIMARY KEY (InstanceId, RuleId) +); + +/* Latest JSON snapshot per kind (server, configuration, databases …) for display. */ +CREATE TABLE insight.Snapshots +( + InstanceId int NOT NULL CONSTRAINT FK_Snapshots_Instances REFERENCES insight.Instances (InstanceId), + Kind varchar(60) NOT NULL, + CapturedAt datetime2(0) NOT NULL, + Json nvarchar(max) NOT NULL, + CONSTRAINT PK_Snapshots PRIMARY KEY (InstanceId, Kind) +); + +CREATE TABLE insight.MetricSamples +( + InstanceId int NOT NULL, + Metric varchar(60) NOT NULL, + SampledAt datetime2(0) NOT NULL, + Value float NOT NULL, + CONSTRAINT PK_MetricSamples PRIMARY KEY (InstanceId, Metric, SampledAt) +); + +CREATE TABLE insight.WaitSamples +( + InstanceId int NOT NULL, + SampledAt datetime2(0) NOT NULL, + WaitType nvarchar(60) NOT NULL, + IntervalSeconds int NOT NULL, + WaitingTasks bigint NOT NULL, + WaitTimeMs bigint NOT NULL, + SignalWaitTimeMs bigint NOT NULL, + CONSTRAINT PK_WaitSamples PRIMARY KEY (InstanceId, SampledAt, WaitType) +); + +CREATE TABLE insight.FileIoSamples +( + InstanceId int NOT NULL, + SampledAt datetime2(0) NOT NULL, + DatabaseName nvarchar(128) NOT NULL, + FileId int NOT NULL, + FileType varchar(10) NOT NULL, + Reads bigint NOT NULL, + ReadStallMs bigint NOT NULL, + Writes bigint NOT NULL, + WriteStallMs bigint NOT NULL, + BytesRead bigint NOT NULL, + BytesWritten bigint NOT NULL, + CONSTRAINT PK_FileIoSamples PRIMARY KEY (InstanceId, SampledAt, DatabaseName, FileId) +); + +/* Hourly aggregates, kept much longer than raw samples. */ +CREATE TABLE insight.MetricHourly +( + InstanceId int NOT NULL, + Metric varchar(60) NOT NULL, + [Hour] datetime2(0) NOT NULL, + AvgValue float NOT NULL, + MaxValue float NOT NULL, + Samples int NOT NULL, + CONSTRAINT PK_MetricHourly PRIMARY KEY (InstanceId, Metric, [Hour]) +); + +CREATE TABLE insight.WaitHourly +( + InstanceId int NOT NULL, + [Hour] datetime2(0) NOT NULL, + WaitType nvarchar(60) NOT NULL, + WaitingTasks bigint NOT NULL, + WaitTimeMs bigint NOT NULL, + SignalWaitTimeMs bigint NOT NULL, + CONSTRAINT PK_WaitHourly PRIMARY KEY (InstanceId, [Hour], WaitType) +); + +CREATE TABLE insight.Deadlocks +( + DeadlockId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Deadlocks PRIMARY KEY NONCLUSTERED, + InstanceId int NOT NULL, + OccurredAt datetime2(3) NOT NULL, + ProcessesJson nvarchar(max) NOT NULL, + GraphXml nvarchar(max) NOT NULL +); +CREATE CLUSTERED INDEX CX_Deadlocks ON insight.Deadlocks (InstanceId, OccurredAt); + +CREATE TABLE insight.BlockingEvents +( + InstanceId int NOT NULL, + SampledAt datetime2(0) NOT NULL, + HeadSessionId int NOT NULL, + BlockedCount int NOT NULL, + MaxWaitMs bigint NOT NULL, + SessionsJson nvarchar(max) NOT NULL, + CONSTRAINT PK_BlockingEvents PRIMARY KEY (InstanceId, SampledAt, HeadSessionId) +); diff --git a/src/SQLInsight.Repository/SQLInsight.Repository.csproj b/src/SQLInsight.Repository/SQLInsight.Repository.csproj new file mode 100644 index 0000000..782372b --- /dev/null +++ b/src/SQLInsight.Repository/SQLInsight.Repository.csproj @@ -0,0 +1,16 @@ + + + SQLInsight.Repository + + + + + + + + + + + + + diff --git a/src/SQLInsight.Repository/SchemaMigrator.cs b/src/SQLInsight.Repository/SchemaMigrator.cs new file mode 100644 index 0000000..ba4cf8b --- /dev/null +++ b/src/SQLInsight.Repository/SchemaMigrator.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Dapper; +using Microsoft.Data.SqlClient; + +namespace SQLInsight.Repository; + +/// +/// Applies the embedded migration scripts in order. Safe to call from several processes at once (the agent and +/// the web UI both call it at startup): an application lock serializes them, and applied versions are recorded. +/// +public static partial class SchemaMigrator +{ + public static async Task MigrateAsync(string connectionString, CancellationToken cancellationToken = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync( + "EXEC sys.sp_getapplock @Resource = N'SQLInsight.SchemaMigration', @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 60000;", + transaction: transaction).ConfigureAwait(false); + await connection.ExecuteAsync(""" + IF SCHEMA_ID(N'insight') IS NULL EXEC (N'CREATE SCHEMA insight AUTHORIZATION dbo;'); + IF OBJECT_ID(N'insight.SchemaVersions') IS NULL + CREATE TABLE insight.SchemaVersions (Version int NOT NULL PRIMARY KEY, Name nvarchar(200) NOT NULL, AppliedAt datetime2(0) NOT NULL); + """, transaction: transaction).ConfigureAwait(false); + + var applied = (await connection.QueryAsync("SELECT Version FROM insight.SchemaVersions", transaction: transaction).ConfigureAwait(false)).ToHashSet(); + var count = 0; + foreach (var (version, name, sql) in Scripts().Where(s => !applied.Contains(s.Version))) + { + foreach (var batch in SplitBatches(sql)) + { + await connection.ExecuteAsync(batch, transaction: transaction, commandTimeout: 600).ConfigureAwait(false); + } + + await connection.ExecuteAsync( + "INSERT insight.SchemaVersions (Version, Name, AppliedAt) VALUES (@version, @name, SYSUTCDATETIME());", + new { version, name }, + transaction).ConfigureAwait(false); + count++; + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return count; + } + + public static IReadOnlyList<(int Version, string Name, string Sql)> Scripts() + { + var assembly = typeof(SchemaMigrator).Assembly; + return assembly.GetManifestResourceNames() + .Select(n => (Name: n, Match: ScriptName().Match(n))) + .Where(x => x.Match.Success) + .Select(x => (int.Parse(x.Match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture), x.Match.Groups[2].Value, Read(assembly, x.Name))) + .OrderBy(x => x.Item1) + .ToList(); + } + + private static string Read(Assembly assembly, string resource) + { + using var stream = assembly.GetManifestResourceStream(resource)!; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private static IEnumerable SplitBatches(string sql) => + GoSeparator().Split(sql).Where(b => !string.IsNullOrWhiteSpace(b)); + + [GeneratedRegex(@"\.(\d{4})_([A-Za-z0-9_]+)\.sql$")] + private static partial Regex ScriptName(); + + [GeneratedRegex(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex GoSeparator(); +} diff --git a/tests/SQLInsight.IntegrationTests/RepositoryTests.cs b/tests/SQLInsight.IntegrationTests/RepositoryTests.cs new file mode 100644 index 0000000..2108647 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/RepositoryTests.cs @@ -0,0 +1,250 @@ +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.IntegrationTests; + +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class RepositoryTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightRepo_" + Guid.NewGuid().ToString("N")[..8]; + private InsightRepository _repository = null!; + + private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero); + + private static readonly InstanceInfo Info = new() + { + ServerName = "SQL01", + Version = SqlServerVersion.Parse("16.0.4135.4"), + ProductLevel = "RTM", + ProductUpdateLevel = "CU14", + Edition = "Enterprise Edition", + EngineEdition = EngineEdition.Enterprise, + }; + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + } + + public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + + [Fact] + public async Task Migrations_are_idempotent_and_safe_to_run_concurrently() + { + var applied = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => _repository.MigrateAsync())); + + Assert.All(applied, count => Assert.Equal(0, count)); + Assert.Equal(SchemaMigrator.Scripts().Count, await ScalarAsync("SELECT COUNT(*) FROM insight.SchemaVersions")); + } + + private static Finding Finding(string subject, Severity severity = Severity.High) => new() + { + RuleId = "SQLI-TEST-001", + Severity = severity, + Title = $"Problem med {subject}", + Subject = subject, + Explanation = "Forklaring", + Recommendation = "Anbefaling", + Evidence = [new Evidence("Måling", "42")], + References = [new Reference("Docs", new Uri("https://learn.microsoft.com/sql/"))], + FixScript = "SELECT 1;", + }; + + private static RuleResult Result(RuleOutcome outcome, params Finding[] findings) => new() + { + RuleId = "SQLI-TEST-001", + RuleTitle = "Testregel", + Outcome = outcome, + Findings = findings, + Reason = outcome == RuleOutcome.NotMeasurable ? "Kræver Query Store" : null, + }; + + [Fact] + public async Task Findings_are_tracked_across_evaluations() + { + var id = await _repository.UpsertInstanceAsync("sql01", Info, "SqlClient", false, Now); + + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A"), Finding("database: B"))], Now); + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A", Severity.Critical))], Now.AddMinutes(15)); + + var active = await _repository.GetActiveFindingsAsync(id); + var a = Assert.Single(active); + Assert.Equal("database: A", a.Subject); + Assert.Equal(Severity.Critical, a.Severity); + Assert.Equal(Now.UtcDateTime, a.FirstSeenAt); + Assert.Equal(Now.AddMinutes(15).UtcDateTime, a.LastSeenAt); + Assert.Equal("42", Assert.Single(a.Evidence).Value); + Assert.Equal("SELECT 1;", a.FixScript); + + var resolved = Assert.Single(await _repository.GetRecentlyResolvedAsync(id, 3650)); + Assert.Equal("database: B", resolved.Subject); + } + + [Fact] + public async Task Not_measurable_does_not_resolve_findings() + { + var id = await _repository.UpsertInstanceAsync("sql02", Info, "SqlClient", false, Now); + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A"))], Now); + + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.NotMeasurable)], Now.AddMinutes(15)); + + Assert.Single(await _repository.GetActiveFindingsAsync(id)); + var state = Assert.Single(await _repository.GetRuleStatesAsync(id)); + Assert.Equal(RuleOutcome.NotMeasurable, state.Outcome); + Assert.Equal("Kræver Query Store", state.Reason); + } + + [Fact] + public async Task Returning_finding_starts_a_new_occurrence() + { + var id = await _repository.UpsertInstanceAsync("sql03", Info, "SqlClient", false, Now); + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("x"))], Now); + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Passed)], Now.AddHours(1)); + await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("x"))], Now.AddHours(2)); + + var active = Assert.Single(await _repository.GetActiveFindingsAsync(id)); + Assert.Equal(Now.AddHours(2).UtcDateTime, active.FirstSeenAt); + Assert.Equal(2, await ScalarAsync($"SELECT COUNT(*) FROM insight.Findings WHERE InstanceId = {id}")); + } + + [Fact] + public async Task Overview_counts_findings_and_not_measurable_rules() + { + var id = await _repository.UpsertInstanceAsync("sql04", Info, "Odbc", true, Now); + await _repository.SaveRuleResultsAsync(id, + [ + Result(RuleOutcome.Findings, Finding("a", Severity.Critical), Finding("b", Severity.Low)), + Result(RuleOutcome.NotMeasurable) with { RuleId = "SQLI-TEST-002" }, + ], Now); + + var overview = Assert.Single(await _repository.GetInstancesAsync(), i => i.Name == "sql04"); + + Assert.Equal((1, 1, 1), (overview.Critical, overview.Low, overview.NotMeasurable)); + Assert.Equal(Severity.Critical, overview.WorstSeverity); + Assert.True(overview.UsedLegacyFallback); + Assert.Equal("16.0.4135.4", overview.ProductVersion); + } + + [Fact] + public async Task Instance_errors_are_recorded_and_shown_as_failing() + { + await _repository.UpsertInstanceAsync("sql05", Info, "SqlClient", false, Now); + await _repository.RecordInstanceErrorAsync("sql05", "Login failed", Now.AddMinutes(5)); + + var overview = Assert.Single(await _repository.GetInstancesAsync(), i => i.Name == "sql05"); + + Assert.True(overview.IsFailing); + Assert.Equal("Login failed", overview.LastError); + } + + [Fact] + public async Task Time_series_are_stored_once_and_aggregated() + { + var id = await _repository.UpsertInstanceAsync("sql06", Info, "SqlClient", false, Now); + var recent = DateTimeOffset.UtcNow; + var cpu = new CpuUtilization + { + CapturedAt = recent, + Samples = Enumerable.Range(0, 10).Select(i => new CpuSample(recent.AddMinutes(-10 + i), 40 + i, 50)).ToList(), + }; + var waits = new WaitStatsDelta { From = recent.AddMinutes(-5), To = recent, Waits = [new WaitStat("LCK_M_X", 5, 3000, 10), new WaitStat("WRITELOG", 100, 300, 5)] }; + var waitSample = new CumulativeSample( + new WaitStatsSnapshot { CapturedAt = recent, SqlServerStartTime = DateTime.UtcNow.AddDays(-1), Waits = [] }, waits); + + await _repository.SaveCollectedAsync(id, "cpu", cpu); + await _repository.SaveCollectedAsync(id, "cpu", cpu); // the ring buffer overlaps between collections + await _repository.SaveCollectedAsync(id, "wait-stats", waitSample); + await _repository.SaveCollectedAsync(id, "wait-stats", waitSample); + await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow); + + var series = await _repository.GetMetricAsync(id, Metrics.CpuSqlServer, recent.AddHours(-1)); + Assert.Equal(10, series.Count); + Assert.Equal(49, series[^1].Value); + Assert.Equal(20, await ScalarAsync($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}")); + Assert.True(await ScalarAsync($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id}") >= 2); + + var top = await _repository.GetTopWaitsAsync(id, recent.AddHours(-1)); + Assert.Equal(["LCK_M_X", "WRITELOG"], top.Select(w => w.WaitType)); + Assert.Equal(3000, top[0].WaitTimeMs); + } + + [Fact] + public async Task Maintenance_deletes_data_past_retention() + { + var id = await _repository.UpsertInstanceAsync("sql07", Info, "SqlClient", false, Now); + await ExecuteAsync($""" + INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -20, SYSUTCDATETIME()), 1), ({id}, 'cpu.sqlserver', DATEADD(day, -1, SYSUTCDATETIME()), 2); + INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -500, SYSUTCDATETIME()), 1, 1, 1); + INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) VALUES ({id}, DATEADD(day, -100, SYSUTCDATETIME()), '[]', ''); + """); + + await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow); + + Assert.Equal(1, await ScalarAsync($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}")); + Assert.Equal(0, await ScalarAsync($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id} AND [Hour] < DATEADD(day, -400, SYSUTCDATETIME())")); + Assert.Equal(0, await ScalarAsync($"SELECT COUNT(*) FROM insight.Deadlocks WHERE InstanceId = {id}")); + } + + [Fact] + public async Task Events_and_snapshots_round_trip() + { + var id = await _repository.UpsertInstanceAsync("sql08", Info, "SqlClient", false, Now); + var at = DateTimeOffset.UtcNow; + var deadlocks = new DeadlockSnapshot + { + CapturedAt = at, + NewDeadlocks = [new DeadlockEvent { Time = at, GraphXml = "", Processes = [new DeadlockProcess { Id = "p1", IsVictim = true, SessionId = 60 }] }], + }; + var blocking = new BlockingSnapshot + { + CapturedAt = at, + Sessions = + [ + new SessionInfo { SessionId = 51, Status = "sleeping", OpenTransactionCount = 1 }, + new SessionInfo { SessionId = 60, BlockingSessionId = 51, WaitTimeMs = 45_000 }, + new SessionInfo { SessionId = 70 }, + new SessionInfo { SessionId = 71, BlockingSessionId = 70, WaitTimeMs = 1_000 }, // too short to store + ], + }; + var config = new ConfigurationSnapshot { CapturedAt = at, Options = [new ConfigurationOption("max degree of parallelism", 8, 8)] }; + + await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks); + await _repository.SaveCollectedAsync(id, "blocking", blocking); + await _repository.SaveCollectedAsync(id, "configuration", config); + + var deadlock = Assert.Single(await _repository.GetDeadlocksAsync(id, at.AddHours(-1))); + Assert.True(Assert.Single(deadlock.Processes).IsVictim); + Assert.Equal("", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml); + + var chain = Assert.Single(await _repository.GetBlockingAsync(id, at.AddHours(-1))); + Assert.Equal((51, 1, 45_000L), (chain.HeadSessionId, chain.BlockedCount, chain.MaxWaitMs)); + + var stored = await _repository.GetSnapshotAsync(id, "configuration"); + Assert.Equal(8, stored!.Find("max degree of parallelism")!.ValueInUse); + } + + private async Task ScalarAsync(string query) + { + await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database)); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = query; + return (T)Convert.ChangeType((await command.ExecuteScalarAsync())!, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + } + + private async Task ExecuteAsync(string query) + { + await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database)); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = query; + await command.ExecuteNonQueryAsync(); + } +} diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj index c9d069d..93983c2 100644 --- a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj +++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj @@ -9,6 +9,7 @@ +