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>
315 lines
16 KiB
C#
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();
|
|
}
|
|
}
|