sqlinsight-pro/tests/SQLInsight.IntegrationTests/RepositoryTests.cs
Søren Eggert Lundsteen Olsen 25a67cb98b Stop SQLInsight deadlocking itself, and stop reporting its own deadlocks
At a customer whose repository sits on a monitored instance, the agent
reported 12 deadlocks that were its own: two instances' monitors save
findings in parallel, one transaction each, and while insight.Findings is
small SQL Server scans it, so the two lock each other's rows.

- SaveRuleResultsAsync takes an exclusive application lock for its
  transaction (the agent is the only writer; a save takes milliseconds)
  and retries if chosen as deadlock victim anyway
- repository connections are named "SQLInsight Pro (repository)"
- deadlocks where every participant is SQLInsight Pro are not reported
  (the read position still moves past them); any other session keeps it

The new integration test (two instances saving 25 rules x 40 rounds in
parallel) failed 2 of 2 without the fix and passed 5 of 5 with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 07:34:59 +02:00

315 lines
16 KiB
C#

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 Parallel_saves_for_different_instances_do_not_deadlock()
{
// The agent monitors instances in parallel; each saves all its rules in one transaction. On a small Findings
// table this deadlocked (seen at a customer, where the repository sits on a monitored instance).
var a = await _repository.UpsertInstanceAsync("sql-a", Info, "SqlClient", false, Now);
var b = await _repository.UpsertInstanceAsync("sql-b", Info, "SqlClient", false, Now);
async Task RunAsync(int instanceId)
{
for (var round = 0; round < 40; round++)
{
string[] subjects = round % 2 == 0 ? ["x", "y"] : ["y", "z"];
var results = Enumerable.Range(1, 25).Select(r =>
{
var ruleId = $"SQLI-PAR-{r:000}";
return new RuleResult
{
RuleId = ruleId,
RuleTitle = "Parallel",
Outcome = RuleOutcome.Findings,
Findings = subjects.Select(s => Finding(s) with { RuleId = ruleId }).ToList(),
};
}).ToList();
await _repository.SaveRuleResultsAsync(instanceId, results, Now.AddMinutes(round));
}
}
await Task.WhenAll(RunAsync(a), RunAsync(b));
// Last round was odd: y and z active for 25 rules on both instances.
Assert.Equal(100, await ScalarAsync<int>("SELECT COUNT(*) FROM insight.Findings WHERE ResolvedAt IS NULL"));
}
[Theory]
[InlineData("Server=.;Database=SQLInsight;Integrated Security=true", "SQLInsight Pro (repository)")]
[InlineData("Server=.;Database=SQLInsight;Integrated Security=true;Application Name=Eget navn", "Eget navn")]
public void Repository_connections_are_named_unless_the_string_names_them(string connectionString, string expected) =>
Assert.Equal(expected, new SqlConnectionStringBuilder(InsightRepository.WithApplicationName(connectionString)).ApplicationName);
[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 Hourly_history_feeds_the_baseline_and_metric_names_match_the_anomaly_rule()
{
Assert.Equal(SQLInsight.Rules.Performance.AnomalyRule.CpuMetric, Metrics.CpuSqlServer);
Assert.Equal(SQLInsight.Rules.Performance.AnomalyRule.BatchMetric, Metrics.BatchRequestsPerSecond);
Assert.Equal(SQLInsight.Rules.Performance.AnomalyRule.PleMetric, Metrics.PageLifeExpectancy);
var id = await _repository.UpsertInstanceAsync("sql09", Info, "SqlClient", false, Now);
var now = DateTimeOffset.UtcNow;
await ExecuteAsync($"""
INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples)
VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -7, SYSUTCDATETIME()), 30, 40, 60),
({id}, 'cpu.sqlserver', DATEADD(day, -40, SYSUTCDATETIME()), 99, 99, 60),
({id}, 'connections.user', DATEADD(day, -7, SYSUTCDATETIME()), 5, 5, 60);
""");
var history = await _repository.GetHourlyHistoryAsync(id, [Metrics.CpuSqlServer], now);
var point = Assert.Single(history);
Assert.Equal(30, point.Value);
Assert.Equal(DateTimeKind.Utc, point.Hour.Kind);
}
[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, "deadlocks", deadlocks); // duplicates are ignored
await _repository.SaveCollectedAsync(id, "blocking", blocking);
await _repository.SaveCollectedAsync(id, "configuration", config);
var deadlock = Assert.Single(await _repository.GetDeadlocksAsync(id, at.AddHours(-1)));
Assert.Equal(InsightRepository.UtcMilliseconds(at), (await _repository.GetLatestDeadlockTimeAsync(id))!.Value.UtcDateTime);
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();
}
}