sqlinsight-pro/tests/SQLInsight.IntegrationTests/CollectorTests.cs
Claude e4910aeba5
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
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
Fase 2: Query Store analysis (plan regressions, failed forced plans, parameter sensitivity)
Collector reads the 100 heaviest queries per Query Store database, split
into the last 24 hours and the week before. Rules:
- SQLI-QS-001 plan regression: new plan >= 2x slower than the best plan
  from the week before and >= 60 s extra per day; scripts the force
- SQLI-QS-002 forced plan that fails (e.g. NO_INDEX); scripts the unforce
- SQLI-QS-003 parameter sensitivity: very different plans or volatile
  durations; suggests PSP optimization / Query Store hints on 2022+
Also: backups taken in the same second a database was created count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 07:33:14 +00:00

287 lines
13 KiB
C#

using Microsoft.Data.SqlClient;
using SQLInsight.Collectors;
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Collectors.Databases;
using SQLInsight.Collectors.Instance;
using SQLInsight.Collectors.Performance;
using SQLInsight.Core.Data;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// Every collector against real SQL Server, as the least-privilege agent login.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class CollectorTests(SqlServerFixture sql)
{
[Fact]
public async Task Every_built_in_collector_runs_with_least_privilege()
{
await using var session = await AgentSession.OpenAsync(sql);
foreach (var collector in BuiltInCollectors.Create())
{
var result = await session.CollectAsync<object>(collector);
Assert.True(collector.DataType.IsInstanceOfType(result), $"{collector.Name} returned {result.GetType().Name}");
}
}
[Fact]
public async Task Server_reports_cpu_memory_and_start_time()
{
await using var session = await AgentSession.OpenAsync(sql);
var server = await session.CollectAsync<ServerSnapshot>(new ServerCollector());
Assert.True(server.LogicalCpuCount > 0);
Assert.True(server.PhysicalMemoryMb > 0);
Assert.True(server.VisibleOnlineSchedulers > 0);
Assert.True(server.SqlServerStartTime < DateTime.Now.AddMinutes(1));
}
[Fact]
public async Task Configuration_contains_well_known_options()
{
await using var session = await AgentSession.OpenAsync(sql);
var config = await session.CollectAsync<ConfigurationSnapshot>(new ConfigurationCollector());
Assert.NotNull(config.Find("max server memory (MB)"));
Assert.Equal(5, config.Find("cost threshold for parallelism")!.ValueInUse);
}
[Fact]
public async Task Databases_include_settings_files_backups_and_query_store()
{
await sql.ExecuteAsSaAsync($"BACKUP DATABASE [{SqlServerFixture.TestDatabase}] TO DISK = N'/tmp/qs.bak' WITH INIT, COPY_ONLY");
await using var session = await AgentSession.OpenAsync(sql);
var snapshot = await session.CollectAsync<DatabaseSnapshot>(new DatabaseCollector());
var master = Assert.Single(snapshot.Databases, d => d.Name == "master");
Assert.True(master.IsSystemDatabase);
Assert.DoesNotContain(snapshot.Databases, d => d.Name == "tempdb");
var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase);
Assert.Equal("ONLINE", db.State);
Assert.Equal("CHECKSUM", db.PageVerify);
Assert.NotNull(db.LastFullBackup);
Assert.Contains(db.Files, f => f.Type == "ROWS");
Assert.Contains(db.Files, f => f.Type == "LOG");
Assert.True(db.VirtualLogFileCount > 0);
Assert.NotNull(db.QueryStore);
Assert.Equal("READ_WRITE", db.QueryStore.ActualState);
Assert.NotNull(db.LastGoodCheckDb); // 1900-01-01: CHECKDB never ran
}
[Fact]
public async Task Wait_stats_produce_a_delta_on_the_second_collection()
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new WaitStatsCollector();
var first = await session.CollectAsync<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>>(collector);
await sql.ExecuteAsSaAsync("WAITFOR DELAY '00:00:01'; SELECT COUNT(*) FROM sys.objects a CROSS JOIN sys.objects b;");
var second = await session.CollectAsync(collector, first);
Assert.Null(first.Delta);
Assert.NotEmpty(first.Snapshot.Waits);
Assert.NotNull(second.Delta);
Assert.DoesNotContain(second.Delta.Waits, w => WaitCatalog.IsBenign(w.WaitType));
}
[Fact]
public async Task File_io_and_counters_produce_deltas()
{
await using var session = await AgentSession.OpenAsync(sql);
var io1 = await session.CollectAsync<CumulativeSample<FileIoSnapshot, FileIoDelta>>(new FileIoCollector());
var io2 = await session.CollectAsync(new FileIoCollector(), io1);
var pc1 = await session.CollectAsync<CumulativeSample<PerformanceCounterSnapshot, PerformanceCounterRates>>(new PerformanceCounterCollector());
await Task.Delay(1100);
var pc2 = await session.CollectAsync(new PerformanceCounterCollector(), pc1);
Assert.Contains(io1.Snapshot.Files, f => f.DatabaseName == "master" && f.FileType == "LOG");
Assert.NotNull(io2.Delta);
Assert.NotNull(pc1.Snapshot.PageLifeExpectancySeconds);
Assert.NotNull(pc1.Snapshot.MemoryGrantsPending);
Assert.NotNull(pc2.Delta?.BatchRequestsPerSecond);
}
[Fact]
public async Task Cpu_history_comes_from_the_scheduler_monitor()
{
await using var session = await AgentSession.OpenAsync(sql);
var cpu = await session.CollectAsync<CpuUtilization>(new CpuCollector());
// The ring buffer gets its first record about a minute after startup.
Assert.All(cpu.Samples, s => Assert.InRange(s.SqlServerPercent + s.IdlePercent, 0, 100));
Assert.Equal(cpu.Samples.OrderBy(s => s.Time), cpu.Samples);
Assert.Equal("Linux", cpu.HostPlatform); // test containers run SQL Server on Linux
Assert.False(cpu.OtherProcessesMeasurable);
}
[Fact]
public async Task Blocking_finds_sleeping_head_blocker_with_open_transaction()
{
await using var blocker = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await blocker.OpenAsync();
await using (var begin = blocker.CreateCommand())
{
begin.CommandText = "BEGIN TRAN; UPDATE dbo.CustomerData SET Secret = N'låst' WHERE Id = 1;";
await begin.ExecuteNonQueryAsync();
}
await using var victim = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await victim.OpenAsync();
await using var blockedCommand = victim.CreateCommand();
blockedCommand.CommandText = "SELECT Secret FROM dbo.CustomerData WHERE Id = 1;";
blockedCommand.CommandTimeout = 30;
var blockedQuery = blockedCommand.ExecuteScalarAsync();
await Task.Delay(1500);
await using var session = await AgentSession.OpenAsync(sql);
var snapshot = await session.CollectAsync<BlockingSnapshot>(new BlockingCollector());
await using (var rollback = blocker.CreateCommand())
{
rollback.CommandText = "ROLLBACK;";
await rollback.ExecuteNonQueryAsync();
}
await blockedQuery;
var head = Assert.Single(snapshot.HeadBlockers);
Assert.Equal("sleeping", head.Status);
Assert.Equal(1, head.OpenTransactionCount);
Assert.Contains("UPDATE dbo.CustomerData", head.SqlText, StringComparison.Ordinal);
var blocked = Assert.Single(snapshot.BlockedBy(head.SessionId));
Assert.StartsWith("LCK_M_S", blocked.WaitType, StringComparison.Ordinal);
Assert.Equal(SqlServerFixture.TestDatabase, blocked.DatabaseName);
// Simple statements are auto-parameterized: SELECT [Secret] FROM [dbo].[CustomerData] WHERE [Id]=@1.
Assert.Contains("CustomerData", blocked.SqlText, StringComparison.Ordinal);
}
[Fact]
public async Task Deadlocks_are_read_from_system_health_and_continue_from_last_position()
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new DeadlockCollector();
var before = await session.CollectAsync<DeadlockSnapshot>(collector);
await CauseDeadlockAsync();
var after = await WaitForDeadlockAsync(session, collector, before);
var deadlock = Assert.Single(after.NewDeadlocks);
Assert.Equal(2, deadlock.Processes.Count);
Assert.Single(deadlock.Processes, p => p.IsVictim);
Assert.All(deadlock.Processes, p => Assert.Contains("UPDATE dbo.CustomerData", p.InputBuffer, StringComparison.Ordinal));
Assert.StartsWith("<deadlock", deadlock.GraphXml, StringComparison.Ordinal);
Assert.NotNull(after.LastFileName);
var again = await session.CollectAsync(collector, after);
Assert.Empty(again.NewDeadlocks);
}
[Fact]
public async Task Query_store_reports_plans_and_failed_forced_plans()
{
// Force a plan that uses an index, then drop the index: forcing fails with NO_INDEX.
// The statement lives in a procedure so it is not auto-parameterized and can be found by object. The data is
// selective enough that the optimizer seeks on the index by itself.
await sql.ExecuteAsSaAsync($"""
USE [{SqlServerFixture.TestDatabase}];
IF OBJECT_ID(N'dbo.QsProbe') IS NULL
BEGIN
CREATE TABLE dbo.QsProbe (Id int IDENTITY PRIMARY KEY, Code int NOT NULL, Filler char(200) NOT NULL DEFAULT 'x');
INSERT dbo.QsProbe (Code) SELECT TOP (50000) ABS(CHECKSUM(NEWID())) % 20000 FROM sys.all_objects a CROSS JOIN sys.all_objects b;
END;
IF INDEXPROPERTY(OBJECT_ID(N'dbo.QsProbe'), N'IX_QsProbe_Code', 'IndexID') IS NULL
CREATE INDEX IX_QsProbe_Code ON dbo.QsProbe (Code);
""");
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC (N'CREATE OR ALTER PROCEDURE dbo.QsProbeProc @code int AS SELECT Id, Filler FROM dbo.QsProbe WHERE Code = @code;');");
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC dbo.QsProbeProc 1; EXEC dbo.QsProbeProc 2; EXEC dbo.QsProbeProc 3; EXEC dbo.QsProbeProc 4; EXEC dbo.QsProbeProc 5; EXEC sys.sp_query_store_flush_db;");
await sql.ExecuteAsSaAsync($"""
USE [{SqlServerFixture.TestDatabase}];
DECLARE @q bigint, @p bigint;
SELECT TOP (1) @q = q.query_id, @p = p.plan_id
FROM sys.query_store_query q JOIN sys.query_store_plan p ON p.query_id = q.query_id
WHERE q.object_id = OBJECT_ID(N'dbo.QsProbeProc');
EXEC sys.sp_query_store_force_plan @q, @p;
DROP INDEX IX_QsProbe_Code ON dbo.QsProbe;
""");
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC dbo.QsProbeProc 42; EXEC sys.sp_query_store_flush_db;");
await using var session = await AgentSession.OpenAsync(sql);
var snapshot = await session.CollectAsync<QueryStoreSnapshot>(new QueryStoreCollector());
var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase);
Assert.Null(db.Error);
var probe = Assert.Single(db.Plans, p => p.IsRecent && p.ObjectName == "dbo.QsProbeProc" && p.Executions >= 5);
Assert.True(probe.IsForced);
var failed = Assert.Single(db.FailedForcedPlans);
Assert.Equal("NO_INDEX", failed.Reason);
}
private static async Task<DeadlockSnapshot> WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous)
{
// system_health dispatches events to its targets with up to 120 seconds latency.
for (var attempt = 0; attempt < 180; attempt++)
{
var snapshot = await session.CollectAsync(collector, previous);
if (snapshot.NewDeadlocks.Count > 0)
{
return snapshot;
}
await Task.Delay(1000);
}
throw new TimeoutException("The deadlock did not appear in system_health.");
}
private async Task CauseDeadlockAsync()
{
await using var a = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await using var b = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await a.OpenAsync();
await b.OpenAsync();
await ExecuteAsync(a, "BEGIN TRAN; UPDATE dbo.CustomerData SET Secret = N'a' WHERE Id = 1;");
await ExecuteAsync(b, "BEGIN TRAN; UPDATE dbo.CustomerData SET Secret = N'b' WHERE Id = 2;");
var first = ExecuteAsync(a, "UPDATE dbo.CustomerData SET Secret = N'a' WHERE Id = 2;");
await Task.Delay(500);
var second = ExecuteAsync(b, "UPDATE dbo.CustomerData SET Secret = N'b' WHERE Id = 1;");
var results = await Task.WhenAll(Capture(first), Capture(second));
Assert.Single(results, e => e is SqlException { Number: 1205 });
await ExecuteAsync(a, "IF @@TRANCOUNT > 0 ROLLBACK;");
await ExecuteAsync(b, "IF @@TRANCOUNT > 0 ROLLBACK;");
}
private static async Task<Exception?> Capture(Task task)
{
try
{
await task;
return null;
}
catch (SqlException ex)
{
return ex;
}
}
private static async Task ExecuteAsync(SqlConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = 30;
await command.ExecuteNonQueryAsync();
}
}