Fase 1: local repository with finding tracking, time series and retention
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

- Versioned, embedded migrations serialized with sp_getapplock so the agent
  and web UI can both migrate at startup
- Findings tracked per rule and subject: first seen, last seen, resolved;
  a rule that could not run never resolves its findings
- Time series (waits, file I/O, CPU, counters), blocking chains and
  deadlock graphs; idempotent inserts (timestamps truncated to match
  datetime2(0)), hourly aggregation and batched retention deletes
- Read API for the web UI; integration tests against SQL Server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 06:23:38 +00:00
parent 63f23c4bbd
commit 9372c2e927
No known key found for this signature in database
11 changed files with 1168 additions and 1 deletions

View file

@ -6,6 +6,7 @@
<!-- Runtime -->
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.0" />
<PackageVersion Include="System.Data.Odbc" Version="10.0.12" />
<PackageVersion Include="Dapper" Version="2.1.89" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />

View file

@ -6,6 +6,7 @@
<Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" />
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
<Project Path="src/SQLInsight.Rules/SQLInsight.Rules.csproj" />
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
</Folder>

View file

@ -33,10 +33,20 @@ public sealed class CpuCollector : Collector<CpuUtilization>
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 };
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -0,0 +1,83 @@
using Dapper;
namespace SQLInsight.Repository;
public sealed class RetentionOptions
{
/// <summary>Raw samples (5-minute waits, per-minute CPU …).</summary>
public int RawDays { get; set; } = 14;
/// <summary>Hourly aggregates, for long trends.</summary>
public int HourlyDays { get; set; } = 400;
/// <summary>Blocking events and deadlock graphs.</summary>
public int EventDays { get; set; } = 90;
/// <summary>Resolved findings.</summary>
public int ResolvedFindingDays { get; set; } = 365;
}
public sealed partial class InsightRepository
{
private const int DeleteBatchSize = 5000;
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -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; }
/// <summary>The most recent contact failed.</summary>
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> Evidence { get; init; } = [];
public IReadOnlyList<Reference> 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<DeadlockProcess> Processes, string GraphXml);
public sealed record StoredBlocking(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, IReadOnlyList<SessionInfo> Sessions);
public sealed partial class InsightRepository
{
public async Task<IReadOnlyList<InstanceOverview>> GetInstancesAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<InstanceOverview>("""
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<InstanceOverview?> 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<IReadOnlyList<StoredFinding>> GetActiveFindingsAsync(int? instanceId = null, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<FindingRow>($"""
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<IReadOnlyList<StoredFinding>> GetRecentlyResolvedAsync(int instanceId, int days, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<FindingRow>($"""
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<StoredFinding?> GetFindingAsync(long findingId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<FindingRow>($"""
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<IReadOnlyList<StoredRuleState>> 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();
}
/// <summary>A metric over time: raw samples within raw retention, hourly averages beyond.</summary>
public async Task<IReadOnlyList<MetricPoint>> 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<MetricPoint>(sql, new { instanceId, metric, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
public async Task<IReadOnlyList<WaitTotal>> 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<WaitTotal>("""
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<IReadOnlyList<FileLatency>> GetFileLatencyAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<FileLatency>("""
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<IReadOnlyList<StoredDeadlock>> 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<List<DeadlockProcess>>(r.ProcessesJson, Json) ?? [], r.GraphXml)).ToList();
}
public async Task<StoredDeadlock?> 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<List<DeadlockProcess>>(r.ProcessesJson, Json) ?? [], r.GraphXml) : null;
}
public async Task<IReadOnlyList<StoredBlocking>> 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<List<SessionInfo>>(r.SessionsJson, Json) ?? [])).ToList();
}
public async Task<T?> GetSnapshotAsync<T>(int instanceId, string kind, CancellationToken cancellationToken = default)
where T : class
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var json = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT Json FROM insight.Snapshots WHERE InstanceId = @instanceId AND Kind = @kind;", new { instanceId, kind }).ConfigureAwait(false);
return json is null ? null : JsonSerializer.Deserialize<T>(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<List<Evidence>>(r.EvidenceJson, Json) ?? [],
References = JsonSerializer.Deserialize<List<Reference>>(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; }
}
}

View file

@ -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;
/// <summary>
/// The on-prem repository: what the agent has found and measured. Lives on a SQL Server instance separate from
/// the ones being monitored.
/// </summary>
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<int> MigrateAsync(CancellationToken cancellationToken = default) => SchemaMigrator.MigrateAsync(connectionString, cancellationToken);
private async Task<SqlConnection> OpenAsync(CancellationToken cancellationToken)
{
var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
/// <summary>
/// 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.
/// </summary>
internal static DateTime Utc(DateTimeOffset value)
{
var utc = value.UtcDateTime;
return new DateTime(utc.Ticks - (utc.Ticks % TimeSpan.TicksPerSecond), DateTimeKind.Utc);
}
/// <summary>Registers a successful contact with an instance and returns its id.</summary>
public async Task<int> 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<int>("""
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);
}
/// <summary>Records that an instance could not be reached or checked.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public async Task SaveRuleResultsAsync(int instanceId, IReadOnlyList<RuleResult> 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);
}
/// <summary>
/// Stores collected data: time series for trends, events (blocking, deadlocks) and the latest snapshot of
/// everything else for display.
/// </summary>
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<WaitStatsSnapshot, WaitStatsDelta> { Delta: { } waits }:
await SaveWaitsAsync(connection, instanceId, waits).ConfigureAwait(false);
return;
case CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>:
return;
case CumulativeSample<FileIoSnapshot, FileIoDelta> { Delta: { } io }:
await SaveFileIoAsync(connection, instanceId, io).ConfigureAwait(false);
return;
case CumulativeSample<FileIoSnapshot, FileIoDelta>:
return;
case CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates> 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<int> 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<int> 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<int> 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<int> SaveCountersAsync(SqlConnection connection, int instanceId, CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates> 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<int> 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),
}));
/// <summary>Blocking shorter than this is normal and not stored.</summary>
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<SessionInfo> Chain(BlockingSnapshot snapshot, int head)
{
var result = new List<SessionInfo>();
var queue = new Queue<int>([head]);
var seen = new HashSet<int> { 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";
}

View file

@ -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)
);

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Repository</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
<ProjectReference Include="..\SQLInsight.Rules\SQLInsight.Rules.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,75 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Dapper;
using Microsoft.Data.SqlClient;
namespace SQLInsight.Repository;
/// <summary>
/// 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.
/// </summary>
public static partial class SchemaMigrator
{
public static async Task<int> 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<int>("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<string> 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();
}

View file

@ -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<int>("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<int>($"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<WaitStatsSnapshot, WaitStatsDelta>(
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<int>($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}"));
Assert.True(await ScalarAsync<int>($"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()), '[]', '<deadlock/>');
""");
await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow);
Assert.Equal(1, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}"));
Assert.Equal(0, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id} AND [Hour] < DATEADD(day, -400, SYSUTCDATETIME())"));
Assert.Equal(0, await ScalarAsync<int>($"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 = "<deadlock/>", 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("<deadlock/>", (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<ConfigurationSnapshot>(id, "configuration");
Assert.Equal(8, stored!.Find("max degree of parallelism")!.ValueInUse);
}
private async Task<T> ScalarAsync<T>(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();
}
}

View file

@ -9,6 +9,7 @@
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Repository\SQLInsight.Repository.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />