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
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:
parent
63f23c4bbd
commit
9372c2e927
11 changed files with 1168 additions and 1 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/SQLInsight.Repository/InsightRepository.Maintenance.cs
Normal file
83
src/SQLInsight.Repository/InsightRepository.Maintenance.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
258
src/SQLInsight.Repository/InsightRepository.Read.cs
Normal file
258
src/SQLInsight.Repository/InsightRepository.Read.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
324
src/SQLInsight.Repository/InsightRepository.cs
Normal file
324
src/SQLInsight.Repository/InsightRepository.cs
Normal 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";
|
||||
}
|
||||
148
src/SQLInsight.Repository/Migrations/0001_initial.sql
Normal file
148
src/SQLInsight.Repository/Migrations/0001_initial.sql
Normal 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)
|
||||
);
|
||||
16
src/SQLInsight.Repository/SQLInsight.Repository.csproj
Normal file
16
src/SQLInsight.Repository/SQLInsight.Repository.csproj
Normal 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>
|
||||
75
src/SQLInsight.Repository/SchemaMigrator.cs
Normal file
75
src/SQLInsight.Repository/SchemaMigrator.cs
Normal 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();
|
||||
}
|
||||
250
tests/SQLInsight.IntegrationTests/RepositoryTests.cs
Normal file
250
tests/SQLInsight.IntegrationTests/RepositoryTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue