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 @@
+