From 4d18d20ab07995c8bc38c2fc432c9b033454bb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 10:42:38 +0200 Subject: [PATCH] tempdb and version store (8c) A five-minute collector reads tempdb space (free, version store, user and internal objects, whether files can grow), the 'Version Store Size (KB)' counter, the sessions using most tempdb, the oldest snapshot transaction and, only when the version store is large, the databases generating versions. Trend and heavy sessions survive restarts; used space and the version store become metrics with explanation and assessment. Rules SQLI-TEMP-001 (version store > 1 GB or steadily growing), SQLI-TEMP-002 (nearly full and cannot grow) and SQLI-TEMP-003 (session above 1 GB for 15 minutes), configurable under SQLInsight:Tempdb. "tempdb lige nu" on the instance page; GET /api/v1/instances/{name}/tempdb. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/analyse.md | 13 ++ src/SQLInsight.Agent/AgentOptions.cs | 12 ++ .../Monitoring/InstanceMonitor.cs | 6 + .../Monitoring/MonitoringService.cs | 3 +- .../BuiltInCollectors.cs | 7 +- .../Instance/TempdbCollector.cs | 91 +++++++++++++ src/SQLInsight.Core/Data/Tempdb.cs | 81 ++++++++++++ src/SQLInsight.Reports/MetricAssessment.cs | 20 +++ src/SQLInsight.Reports/TimeWindowAnalysis.cs | 2 + .../InsightRepository.cs | 10 ++ src/SQLInsight.Rules/BuiltInRules.cs | 3 + src/SQLInsight.Rules/Instance/TempdbRules.cs | 123 ++++++++++++++++++ src/SQLInsight.Web/Api/ApiV1Endpoints.cs | 27 ++++ .../Components/Pages/Instance.razor | 18 +++ .../TempdbTests.cs | 82 ++++++++++++ .../RuleCatalogTests.cs | 2 +- .../SQLInsight.Rules.Tests/TempdbRuleTests.cs | 51 ++++++++ 18 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 src/SQLInsight.Collectors/Instance/TempdbCollector.cs create mode 100644 src/SQLInsight.Core/Data/Tempdb.cs create mode 100644 src/SQLInsight.Rules/Instance/TempdbRules.cs create mode 100644 tests/SQLInsight.IntegrationTests/TempdbTests.cs create mode 100644 tests/SQLInsight.Rules.Tests/TempdbRuleTests.cs diff --git a/README.md b/README.md index 9cd3c51..ad1ab0b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA. -> **Status:** Fase 1–4 er implementeret: agent, 75 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). +> **Status:** Fase 1–4 er implementeret: agent, 78 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). --- diff --git a/docs/analyse.md b/docs/analyse.md index 2922b78..2d0342a 100644 --- a/docs/analyse.md +++ b/docs/analyse.md @@ -119,3 +119,16 @@ Hvert minut ser agenten på brugertransaktioner (`sys.dm_tran_*`) og kørende s | SQLI-TRAN-001 | Middel, Høj ved 6 × grænsen | En transaktion har været åben over `SQLInsight:Activity:TransactionMinutes` (standard 10 minutter) | | SQLI-TRAN-002 | Middel | En transaktion holder over `TransactionLogMegabytes` (standard 1.024 MB) log | | SQLI-REQ-001 | Middel | En brugersætning har kørt over `RequestMinutes` (standard 15 minutter). BACKUP, RESTORE og DBCC (langvarige operationer) og sætninger, der venter med vilje (WAITFOR, Service Broker), tælles ikke | + +## tempdb og version store + +Hvert 5. minut læser agenten tempdb's plads (`tempdb.sys.dm_db_file_space_usage`: fri plads, version store, brugerobjekter og interne objekter), om datafilerne kan vokse, tælleren `Version Store Size (KB)`, de sessioner der bruger mest tempdb (`sys.dm_db_session_space_usage` og `sys.dm_db_task_space_usage`), den ældste snapshot-transaktion (`sys.dm_tran_active_snapshot_database_transactions`) og – kun når version store er over halvdelen af grænsen, fordi det kræver en gennemgang af version store – hvilke databaser der laver versionerne (`sys.dm_tran_top_version_generators`). + +- **tempdb lige nu** på instanssiden, og graferne "tempdb i brug" og "Version store" med forklaring og vurdering (også i analysen af et tidsrum). +- API: `GET /api/v1/instances/{navn}/tempdb` (login og program er `[udeladt]`). + +| Regel | Alvor | Hvornår | +|---|---|---| +| SQLI-TEMP-001 | Middel | Version store over `SQLInsight:Tempdb:VersionStoreMegabytes` (standard 1.024 MB), eller vokset i hver af de seneste 6 målinger med mindst 256 MB i alt | +| SQLI-TEMP-002 | Høj | Under 10 % fri plads, og ingen datafil kan vokse | +| SQLI-TEMP-003 | Middel | En session har brugt over `SessionMegabytes` (standard 1.024 MB) tempdb i mindst 15 minutter | diff --git a/src/SQLInsight.Agent/AgentOptions.cs b/src/SQLInsight.Agent/AgentOptions.cs index b5778a5..cbe25e5 100644 --- a/src/SQLInsight.Agent/AgentOptions.cs +++ b/src/SQLInsight.Agent/AgentOptions.cs @@ -28,6 +28,8 @@ public sealed class AgentOptions public ActivityOptions Activity { get; set; } = new(); + public TempdbOptions Tempdb { get; set; } = new(); + public IList Instances { get; } = []; } @@ -110,3 +112,13 @@ public sealed class ActivityOptions /// A user statement running longer than this (minutes) is a finding (SQLI-REQ-001). public int RequestMinutes { get; set; } = 15; } + +/// tempdb and the version store. +public sealed class TempdbOptions +{ + /// The version store above this (MB) is a finding (SQLI-TEMP-001). + public int VersionStoreMegabytes { get; set; } = 1024; + + /// A session using more tempdb than this (MB) for 15 minutes is a finding (SQLI-TEMP-003). + public int SessionMegabytes { get; set; } = 1024; +} diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs index ba004b8..e20ded0 100644 --- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs +++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs @@ -252,6 +252,12 @@ public sealed partial class InstanceMonitor( return await repository.GetSnapshotAsync(instanceId, collector.Name, cancellationToken).ConfigureAwait(false); } + // The version store trend and heavy sessions continue after a restart. + if (collector is Collectors.Instance.TempdbCollector) + { + return await repository.GetSnapshotAsync(instanceId, collector.Name, cancellationToken).ConfigureAwait(false); + } + if (collector is not Collectors.Concurrency.DeadlockCollector) { return null; diff --git a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs index 706406c..237ad62 100644 --- a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs +++ b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs @@ -33,7 +33,8 @@ public sealed partial class MonitoringService( var intervals = new Dictionary(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase); var monitors = settings.Instances .Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst, - settings.Activity.TransactionMinutes, settings.Activity.TransactionLogMegabytes, settings.Activity.RequestMinutes)), intervals, connectionFactory, ruleEngine, repository, + settings.Activity.TransactionMinutes, settings.Activity.TransactionLogMegabytes, settings.Activity.RequestMinutes, + settings.Tempdb.VersionStoreMegabytes, settings.Tempdb.SessionMegabytes)), intervals, connectionFactory, ruleEngine, repository, loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox)) .ToList(); if (monitors.Count == 0) diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs index 26f9f8a..b7005d5 100644 --- a/src/SQLInsight.Collectors/BuiltInCollectors.cs +++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs @@ -28,6 +28,7 @@ public static class BuiltInCollectors new WaitSamplingCollector(), new LongOperationCollector(), new ErrorLogCollector(settings.LoginFailureBurst), + new TempdbCollector(settings.VersionStoreMegabytes, settings.TempdbSessionMegabytes), new PerformanceCounterCollector(), new CpuCollector(), new BlockingCollector(), @@ -51,6 +52,8 @@ public static class BuiltInCollectors /// An open transaction older than this is a finding. /// A transaction holding more log than this is a finding. /// A statement running longer than this is a finding. +/// The version store above this is a finding. +/// A session using more tempdb than this for 15 minutes is a finding. public sealed record CollectorSettings( Core.Data.IGroupMemberResolver? Groups = null, int MaxSysadminAccounts = 3, @@ -58,4 +61,6 @@ public sealed record CollectorSettings( int LoginFailureBurst = 20, int TransactionMinutes = 10, int TransactionLogMegabytes = 1024, - int RequestMinutes = 15); + int RequestMinutes = 15, + int VersionStoreMegabytes = 1024, + int TempdbSessionMegabytes = 1024); diff --git a/src/SQLInsight.Collectors/Instance/TempdbCollector.cs b/src/SQLInsight.Collectors/Instance/TempdbCollector.cs new file mode 100644 index 0000000..5ffe1fd --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/TempdbCollector.cs @@ -0,0 +1,91 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Instance; + +/// +/// tempdb space (free, version store, user and internal objects), the sessions using most of it, the oldest snapshot +/// transaction and which databases generate row versions. VIEW SERVER STATE; SQL Server 2005+. +/// +public sealed class TempdbCollector(int versionStoreMegabytes = 1024, int sessionMegabytes = 1024) : Collector +{ + internal const string SpaceSql = """ + SELECT SUM(CAST(u.unallocated_extent_page_count AS bigint)), SUM(CAST(u.version_store_reserved_page_count AS bigint)), + SUM(CAST(u.user_object_reserved_page_count AS bigint)), SUM(CAST(u.internal_object_reserved_page_count AS bigint)), + (SELECT SUM(CAST(f.size AS bigint)) FROM tempdb.sys.database_files f WHERE f.type = 0), + (SELECT MAX(CASE WHEN f.growth > 0 AND (f.max_size = -1 OR f.size < f.max_size) THEN 1 ELSE 0 END) FROM tempdb.sys.database_files f WHERE f.type = 0), + (SELECT MAX(CAST(p.cntr_value AS bigint)) FROM sys.dm_os_performance_counters p + WHERE p.counter_name = N'Version Store Size (KB)' AND p.object_name LIKE N'%Transactions%') + FROM tempdb.sys.dm_db_file_space_usage u + """; + + internal const string SessionsSql = """ + SELECT TOP 5 u.session_id, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), s.login_time), u.pages, s.login_name, s.program_name, s.host_name, s.status + FROM (SELECT a.session_id, SUM(a.uo_alloc - a.uo_dealloc + a.io_alloc - a.io_dealloc) AS pages + FROM (SELECT session_id, CAST(user_objects_alloc_page_count AS bigint) AS uo_alloc, CAST(user_objects_dealloc_page_count AS bigint) AS uo_dealloc, + CAST(internal_objects_alloc_page_count AS bigint) AS io_alloc, CAST(internal_objects_dealloc_page_count AS bigint) AS io_dealloc + FROM sys.dm_db_session_space_usage + UNION ALL + SELECT session_id, CAST(user_objects_alloc_page_count AS bigint), CAST(user_objects_dealloc_page_count AS bigint), + CAST(internal_objects_alloc_page_count AS bigint), CAST(internal_objects_dealloc_page_count AS bigint) + FROM sys.dm_db_task_space_usage) a + GROUP BY a.session_id) u + JOIN sys.dm_exec_sessions s ON s.session_id = u.session_id + WHERE s.is_user_process = 1 AND u.pages > 0 + ORDER BY u.pages DESC + """; + + internal const string OldestSnapshotSql = """ + SELECT TOP 1 t.session_id, t.elapsed_time_seconds, s.login_name, s.program_name + FROM sys.dm_tran_active_snapshot_database_transactions t + LEFT JOIN sys.dm_exec_sessions s ON s.session_id = t.session_id + ORDER BY t.elapsed_time_seconds DESC + """; + + // Scans the version store: only run when it is large enough to need explaining. + internal const string GeneratorsSql = """ + SELECT TOP 10 DB_NAME(database_id), SUM(CAST(aggregated_record_length_in_bytes AS bigint)) + FROM sys.dm_tran_top_version_generators + GROUP BY database_id + ORDER BY SUM(CAST(aggregated_record_length_in_bytes AS bigint)) DESC + """; + + public override string Name => "tempdb"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5); + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task CollectAsync(CollectorContext context, TempdbSnapshot? previous) + { + var ct = context.CancellationToken; + var connection = context.Connection; + const double PageMb = 8.0 / 1024; + var space = (await Db.QueryAsync(connection, SpaceSql, r => ( + Free: r.NullableLong(0) ?? 0, Version: r.NullableLong(1) ?? 0, User: r.NullableLong(2) ?? 0, Internal: r.NullableLong(3) ?? 0, + Size: r.NullableLong(4) ?? 0, CanGrow: (r.NullableInt(5) ?? 1) == 1, VersionKb: r.NullableLong(6)), ct).ConfigureAwait(false))[0]; + var sessions = await Db.QueryAsync(connection, SessionsSql, r => new TempdbSession( + r.Int(0), new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(1), DateTimeKind.Utc)), r.Long(2) * PageMb, r.NullableString(3), r.NullableString(4), + r.NullableString(5), r.NullableString(6)), ct).ConfigureAwait(false); + var oldest = (await Db.QueryAsync(connection, OldestSnapshotSql, r => new SnapshotTransaction(r.Int(0), r.NullableLong(1) ?? 0, r.NullableString(2), r.NullableString(3)), ct) + .ConfigureAwait(false)).FirstOrDefault(); + var versionMb = space.VersionKb is { } kb ? kb / 1024.0 : space.Version * PageMb; + var generators = versionMb >= versionStoreMegabytes / 2.0 + ? await Db.QueryAsync(connection, GeneratorsSql, r => new VersionGenerator(r.NullableString(0) ?? "?", (r.NullableLong(1) ?? 0) / 1024.0 / 1024.0), ct).ConfigureAwait(false) + : []; + + return TempdbAnalysis.Advance(previous, new TempdbSnapshot + { + CapturedAt = context.Now, + SizeMegabytes = space.Size * PageMb, + FreeMegabytes = space.Free * PageMb, + VersionStoreMegabytes = versionMb, + UserObjectsMegabytes = space.User * PageMb, + InternalObjectsMegabytes = space.Internal * PageMb, + CanGrow = space.CanGrow, + TopSessions = sessions, + OldestSnapshotTransaction = oldest, + VersionGenerators = generators, + VersionStoreThresholdMegabytes = versionStoreMegabytes, + SessionThresholdMegabytes = sessionMegabytes, + }); + } +} diff --git a/src/SQLInsight.Core/Data/Tempdb.cs b/src/SQLInsight.Core/Data/Tempdb.cs new file mode 100644 index 0000000..4922210 --- /dev/null +++ b/src/SQLInsight.Core/Data/Tempdb.cs @@ -0,0 +1,81 @@ +namespace SQLInsight.Core.Data; + +/// A session using tempdb (user objects plus internal objects: sorts, hash tables, spools). Login and program are sensitive. +/// When the session logged in; with it identifies the session across samples. +public sealed record TempdbSession(int SessionId, DateTimeOffset Since, double UsedMegabytes, string? LoginName, string? ProgramName, string? HostName, string? Status) +{ + public string Key => $"{SessionId}|{Since.UtcTicks}"; +} + +/// The oldest open snapshot transaction: it keeps every row version made since it began. +public sealed record SnapshotTransaction(int SessionId, long ElapsedSeconds, string? LoginName, string? ProgramName); + +/// Version store use per database (from sys.dm_tran_top_version_generators). +public sealed record VersionGenerator(string DatabaseName, double Megabytes); + +/// tempdb space and the version store, every 5 minutes. +public sealed record TempdbSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// The size of tempdb's data files now. + public double SizeMegabytes { get; init; } + public double FreeMegabytes { get; init; } + public double VersionStoreMegabytes { get; init; } + public double UserObjectsMegabytes { get; init; } + public double InternalObjectsMegabytes { get; init; } + + /// Whether a data file can still grow (autogrowth on and not at its maximum size). + public bool CanGrow { get; init; } = true; + + public IReadOnlyList TopSessions { get; init; } = []; + public SnapshotTransaction? OldestSnapshotTransaction { get; init; } + public IReadOnlyList VersionGenerators { get; init; } = []; + + /// Version store sizes of the latest samples (MB), oldest first: the trend. + public IReadOnlyList VersionStoreHistory { get; init; } = []; + + /// Since when each session has been above , by . + public IReadOnlyDictionary HeavySessionsSince { get; init; } = new Dictionary(); + + /// The version store above this is a finding (SQLInsight:Tempdb:VersionStoreMegabytes). + public int VersionStoreThresholdMegabytes { get; init; } = 1024; + + /// A session using more tempdb than this for is a finding. + public int SessionThresholdMegabytes { get; init; } = 1024; + + public double FreePercent => SizeMegabytes <= 0 ? 100 : 100 * FreeMegabytes / SizeMegabytes; +} + +public static class TempdbAnalysis +{ + public const int HistoryLength = 12; + public const int HeavySessionMinutes = 15; + + /// The version store has grown in each of the last six samples, by a quarter GB in all. + public static bool IsGrowing(IReadOnlyList history) + { + ArgumentNullException.ThrowIfNull(history); + if (history.Count < 6) + { + return false; + } + + var last = history.Skip(history.Count - 6).ToList(); + return last.Zip(last.Skip(1)).All(p => p.Second > p.First) && last[^1] - last[0] >= 256; + } + + /// Carries the trend and the heavy sessions over from the previous sample. + public static TempdbSnapshot Advance(TempdbSnapshot? previous, TempdbSnapshot current) + { + ArgumentNullException.ThrowIfNull(current); + var history = (previous?.VersionStoreHistory ?? []).Append(current.VersionStoreMegabytes).TakeLast(HistoryLength).ToList(); + var heavy = new Dictionary(StringComparer.Ordinal); + foreach (var s in current.TopSessions.Where(s => s.UsedMegabytes >= current.SessionThresholdMegabytes)) + { + heavy[s.Key] = previous?.HeavySessionsSince.TryGetValue(s.Key, out var since) == true ? since : current.CapturedAt; + } + + return current with { VersionStoreHistory = history, HeavySessionsSince = heavy }; + } +} diff --git a/src/SQLInsight.Reports/MetricAssessment.cs b/src/SQLInsight.Reports/MetricAssessment.cs index 521b748..62324cd 100644 --- a/src/SQLInsight.Reports/MetricAssessment.cs +++ b/src/SQLInsight.Reports/MetricAssessment.cs @@ -97,6 +97,12 @@ public static class MetricAssessment + "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).", Metrics.ProcessesBlocked => "Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.", + Metrics.TempdbUsedMegabytes => + "Hvor meget af tempdb der er i brug: temp-tabeller, sorteringer og hash-tabeller, der ikke er plads til i hukommelsen, og version store. " + + "Pludselige toppe skyldes typisk en enkelt tung forespørgsel eller et indexvedligehold; se Lige nu og version store.", + Metrics.VersionStoreMegabytes => + "Gamle udgaver af rækker, som snapshot isolation og READ_COMMITTED_SNAPSHOT (AX 2009/2012) holder i tempdb. Den bør gå op og ned; " + + "vokser den hele tiden, holder en lang transaktion de gamle versioner fast.", "disk.read" => "Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.", "disk.write.log" => @@ -135,6 +141,8 @@ public static class MetricAssessment Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok, $"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."), Metrics.UserConnections => Connections(points), + Metrics.VersionStoreMegabytes => VersionStore(points), + Metrics.TempdbUsedMegabytes => new(AssessmentLevel.Ok, $"I gennemsnit {N(Average(points), 0)} MB i brug, højst {N(Peak(points), 0)} MB."), _ => new(AssessmentLevel.Unknown, "Ingen vurdering."), }; } @@ -286,6 +294,18 @@ public static class MetricAssessment : new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link); } + private static Assessment VersionStore(IReadOnlyList points) + { + var summary = $"I gennemsnit {N(Average(points), 0)} MB, højst {N(Peak(points), 0)} MB."; + var growing = TempdbAnalysis.IsGrowing([.. points.OrderBy(p => p.At).Select(p => p.Value)]); + return Peak(points) >= 1024 || growing + ? new(AssessmentLevel.Act, summary + (growing ? " Den vokser støt." : " Over 1 GB."), + "Find den ældste snapshot-transaktion og lange transaktioner (Lige nu på instansens side) og få dem afsluttet.", "#aktivitet") + : Peak(points) >= 512 + ? new(AssessmentLevel.Note, summary, "Hold øje med, om den falder igen.") + : new(AssessmentLevel.Ok, summary); + } + private static Assessment Connections(IReadOnlyList points) { var avg = Average(points); diff --git a/src/SQLInsight.Reports/TimeWindowAnalysis.cs b/src/SQLInsight.Reports/TimeWindowAnalysis.cs index c8e29cf..993df52 100644 --- a/src/SQLInsight.Reports/TimeWindowAnalysis.cs +++ b/src/SQLInsight.Reports/TimeWindowAnalysis.cs @@ -71,6 +71,8 @@ public sealed record TimeWindowAnalysis (Repository.Metrics.UserConnections, "Brugerforbindelser", "", 0, false, null), (Repository.Metrics.MemoryGrantsPending, "Memory grants pending", "", 0, false, null), (Repository.Metrics.ProcessesBlocked, "Blokerede processer", "", 0, false, null), + (Repository.Metrics.TempdbUsedMegabytes, "tempdb i brug (MB)", "", 0, false, null), + (Repository.Metrics.VersionStoreMegabytes, "Version store (MB)", "", 0, false, null), ]; public static async Task LoadAsync( diff --git a/src/SQLInsight.Repository/InsightRepository.cs b/src/SQLInsight.Repository/InsightRepository.cs index 324b34c..c92b590 100644 --- a/src/SQLInsight.Repository/InsightRepository.cs +++ b/src/SQLInsight.Repository/InsightRepository.cs @@ -272,6 +272,14 @@ public sealed partial class InsightRepository(string connectionString) await SaveActivityAsync(connection, instanceId, activity).ConfigureAwait(false); await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false); return; + case TempdbSnapshot tempdb: + await InsertMetricsAsync(connection, instanceId, + [ + (Metrics.TempdbUsedMegabytes, tempdb.CapturedAt, tempdb.SizeMegabytes - tempdb.FreeMegabytes), + (Metrics.VersionStoreMegabytes, tempdb.CapturedAt, tempdb.VersionStoreMegabytes), + ]).ConfigureAwait(false); + await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false); + return; case PrivilegeSnapshot privileges: await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false); await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false); @@ -492,4 +500,6 @@ public static class Metrics public const string ProcessesBlocked = "blocking.processes"; public const string BatchRequestsPerSecond = "workload.batch_requests"; public const string CompilationsPerSecond = "workload.compilations"; + public const string TempdbUsedMegabytes = "tempdb.used_mb"; + public const string VersionStoreMegabytes = "tempdb.version_store_mb"; } diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index 1b00efa..aa9f1db 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -66,6 +66,9 @@ public static class BuiltInRules new LongTransactionRule(), new LargeTransactionRule(), new LongRunningStatementRule(), + new VersionStoreRule(), + new TempdbFullRule(), + new TempdbSessionRule(), new MemoryGrantsPendingRule(), new AnomalyRule(), new PlanRegressionRule(), diff --git a/src/SQLInsight.Rules/Instance/TempdbRules.cs b/src/SQLInsight.Rules/Instance/TempdbRules.cs new file mode 100644 index 0000000..e22d976 --- /dev/null +++ b/src/SQLInsight.Rules/Instance/TempdbRules.cs @@ -0,0 +1,123 @@ +using System.Globalization; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Instance; + +/// +/// The version store in tempdb (row versions for snapshot isolation, READ_COMMITTED_SNAPSHOT, triggers, online index +/// operations) is large or keeps growing: something keeps old versions alive, usually a long-open transaction. +/// +public sealed class VersionStoreRule : Rule +{ + public override string Id => "SQLI-TEMP-001"; + public override string Title => "Version store i tempdb er stor eller vokser"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var t = context.Data.Get(); + var large = t.VersionStoreMegabytes >= t.VersionStoreThresholdMegabytes; + var growing = TempdbAnalysis.IsGrowing(t.VersionStoreHistory); + if (!large && !growing) + { + return RuleEvaluation.Passed(); + } + + var evidence = new List + { + new("Version store", $"{Format.Number(t.VersionStoreMegabytes)} MB"), + new("Grænse", $"{t.VersionStoreThresholdMegabytes.ToString(CultureInfo.InvariantCulture)} MB"), + new("Udvikling", string.Join(" → ", t.VersionStoreHistory.TakeLast(6).Select(v => Format.Number(v)))), + }; + if (t.VersionGenerators.Count > 0) + { + evidence.Add(new("Databaser, der laver versioner", string.Join(", ", t.VersionGenerators.Take(5).Select(g => $"{g.DatabaseName} {Format.Number(g.Megabytes)} MB")))); + } + + if (t.OldestSnapshotTransaction is { } oldest) + { + evidence.Add(new("Ældste snapshot-transaktion", $"session {oldest.SessionId}, {Format.Age(TimeSpan.FromSeconds(oldest.ElapsedSeconds))}")); + evidence.Add(new("Dens login og program", $"{oldest.LoginName} {oldest.ProgramName}".Trim(), Sensitive: true)); + } + + return RuleEvaluation.Found(CreateFinding( + Severity.Medium, + large ? $"Version store i tempdb fylder {Format.Number(t.VersionStoreMegabytes)} MB" : "Version store i tempdb vokser støt", + "Version store holder gamle udgaver af rækker til snapshot isolation og READ_COMMITTED_SNAPSHOT (AX 2009/2012 bruger det), triggere og online " + + "indexvedligehold. Versionerne kan først fjernes, når den ældste transaktion, der kan have brug for dem, er afsluttet. Vokser den hele tiden, " + + "er det næsten altid en transaktion, der har stået åben længe.", + "Find den ældste snapshot-transaktion (se målingerne) eller lange transaktioner under Lige nu på instansens side, og få den afsluttet. " + + "Se hvilke databaser, der laver flest versioner. Sørg for, at tempdb har plads nok, så den ikke løber fuld imens.", + evidence, + subject: "tempdb: version store")); + } +} + +/// tempdb is nearly full and cannot grow: queries needing it will fail. +public sealed class TempdbFullRule : Rule +{ + public const double FreePercent = 10; + + public override string Id => "SQLI-TEMP-002"; + public override string Title => "tempdb er næsten fuld"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var t = context.Data.Get(); + if (t.CanGrow || t.FreePercent >= FreePercent) + { + return RuleEvaluation.Passed(); + } + + return RuleEvaluation.Found(CreateFinding( + Severity.High, + $"tempdb har kun {Format.Number(t.FreePercent)} % fri plads og kan ikke vokse", + "Når tempdb er fuld, fejler sorteringer, temp-tabeller, version store og indexvedligehold (fejl 1105/9002), og SQL Server kan blive ustabil.", + "Find hvad der fylder (version store, brugerobjekter eller interne objekter, og de sessioner, der bruger mest). Giv tempdb mere plads " + + "eller autogrowth, og løs årsagen (ofte en lang transaktion eller en forespørgsel, der sorterer enorme mængder).", + [ + new Evidence("Størrelse", $"{Format.Number(t.SizeMegabytes)} MB"), + new Evidence("Fri", $"{Format.Number(t.FreeMegabytes)} MB"), + new Evidence("Version store", $"{Format.Number(t.VersionStoreMegabytes)} MB"), + new Evidence("Brugerobjekter", $"{Format.Number(t.UserObjectsMegabytes)} MB"), + new Evidence("Interne objekter", $"{Format.Number(t.InternalObjectsMegabytes)} MB"), + ], + subject: "tempdb: full")); + } +} + +/// A session that has used a lot of tempdb for a long time. +public sealed class TempdbSessionRule : Rule +{ + public override string Id => "SQLI-TEMP-003"; + public override string Title => "Session bruger meget tempdb i lang tid"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var t = context.Data.Get(); + return Result(t.TopSessions + .Where(s => t.HeavySessionsSince.TryGetValue(s.Key, out var since) && t.CapturedAt - since >= TimeSpan.FromMinutes(TempdbAnalysis.HeavySessionMinutes)) + .Select(s => CreateFinding( + Severity.Medium, + $"Session {s.SessionId} har brugt over {Format.Number(t.SessionThresholdMegabytes)} MB tempdb i over {TempdbAnalysis.HeavySessionMinutes} minutter", + "En session holder store temp-tabeller, tabelvariabler eller interne objekter (sorteringer, hash-tabeller, spools) i tempdb. " + + "Det fylder tempdb for alle andre og tyder på en tung forespørgsel eller temp-tabeller, der ikke ryddes op.", + "Se hvad sessionen kører (Lige nu på instansens side). Ryd temp-tabeller op, når de ikke bruges, og se på forespørgsler, der spilder til tempdb.", + [ + new Evidence("Brugt", $"{Format.Number(s.UsedMegabytes)} MB"), + new Evidence("Over grænsen siden", t.HeavySessionsSince[s.Key].UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)), + new Evidence("Session", s.SessionId.ToString(CultureInfo.InvariantCulture)), + new Evidence("Login", s.LoginName ?? "–", Sensitive: true), + new Evidence("Program", s.ProgramName ?? "–", Sensitive: true), + ], + subject: $"tempdb session: {s.SessionId}/{s.Since.UtcDateTime:yyyyMMddHHmmss}"))); + } +} diff --git a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs index 30aed16..2baae9a 100644 --- a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs +++ b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs @@ -47,6 +47,7 @@ public static class ApiV1Endpoints api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance"); api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances"); api.MapGet("/instances/{name}/activity", ActivityAsync).WithSummary("Åbne transaktioner og lange sætninger lige nu / Open transactions and long-running statements"); + api.MapGet("/instances/{name}/tempdb", TempdbAsync).WithSummary("tempdb og version store lige nu / tempdb and the version store"); api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)"); api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type"); } @@ -527,6 +528,32 @@ public static class ApiV1Endpoints }); } + private static async Task TempdbAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance) + { + return Results.NotFound(); + } + + var redact = RedactorFor(options); + var t = await repository.GetSnapshotAsync(instance.InstanceId, "tempdb", ct).ConfigureAwait(false); + return Results.Ok(t is null ? new { Instance = instance.Name } : (object)new + { + Instance = instance.Name, + MeasuredAt = t.CapturedAt, + t.SizeMegabytes, t.FreeMegabytes, t.FreePercent, t.CanGrow, t.VersionStoreMegabytes, t.UserObjectsMegabytes, t.InternalObjectsMegabytes, + t.VersionStoreThresholdMegabytes, t.VersionStoreHistory, t.VersionGenerators, + OldestSnapshotTransaction = t.OldestSnapshotTransaction is { } o + ? new { o.SessionId, o.ElapsedSeconds, LoginName = redact.Value(o.LoginName), ProgramName = redact.Value(o.ProgramName) } + : null, + TopSessions = t.TopSessions.Select(s => new + { + s.SessionId, s.Since, s.UsedMegabytes, s.Status, LoginName = redact.Value(s.LoginName), ProgramName = redact.Value(s.ProgramName), HostName = redact.Value(s.HostName), + }), + Explanation = new { VersionStore = MetricAssessment.Explanation(Metrics.VersionStoreMegabytes), Used = MetricAssessment.Explanation(Metrics.TempdbUsedMegabytes) }, + }); + } + private static async Task ErrorLogAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct, int? days = null) { if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance) diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index e7545e5..dfd82d4 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -136,6 +136,22 @@ else +
+

tempdb lige nu

@(_tempdb is null ? "endnu ikke målt" : "målt " + Display.Ago(_tempdb.CapturedAt.UtcDateTime))
+ @if (_tempdb is { } td) + { + + + + + + + + +
Størrelse / fri@Display.Number(td.SizeMegabytes) MB / @Display.Number(td.FreeMegabytes) MB (@Display.Number(td.FreePercent) %)@(td.CanGrow ? "" : ", kan ikke vokse")
Version store@Display.Number(td.VersionStoreMegabytes) MB@(td.VersionGenerators.Count > 0 ? " · " + string.Join(", ", td.VersionGenerators.Take(3).Select(g => $"{g.DatabaseName} {Display.Number(g.Megabytes)} MB")) : "")
Brugerobjekter / interne@Display.Number(td.UserObjectsMegabytes) MB / @Display.Number(td.InternalObjectsMegabytes) MB
Ældste snapshot-transaktion@(td.OldestSnapshotTransaction is { } o ? $"session {o.SessionId}, {TransactionTable.Duration(TimeSpan.FromSeconds(o.ElapsedSeconds))}" : "ingen")
Største forbrugere@(td.TopSessions.Count == 0 ? "–" : string.Join(", ", td.TopSessions.Take(5).Select(s => $"session {s.SessionId} {Display.Number(s.UsedMegabytes)} MB")))
+ } +
+

Lige nu: åbne transaktioner og lange sætninger

@(_activity is null ? "endnu ikke målt" : "målt " + Display.Ago(_activity.CapturedAt.UtcDateTime))
@if (_activity is not { } activity || activity.Transactions.Count == 0 && activity.LongRequests.Count == 0) @@ -303,6 +319,7 @@ else private IReadOnlyList _blocking = []; private IReadOnlyList _operations = []; private ActivitySnapshot? _activity; + private TempdbSnapshot? _tempdb; private IReadOnlyList _deadlocks = []; protected override async Task OnInitializedAsync() @@ -337,6 +354,7 @@ else _blocking = await Repository.GetBlockingAsync(id, day); _operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50); _activity = await Repository.GetSnapshotAsync(id, "activity"); + _tempdb = await Repository.GetSnapshotAsync(id, "tempdb"); _deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7)); } diff --git a/tests/SQLInsight.IntegrationTests/TempdbTests.cs b/tests/SQLInsight.IntegrationTests/TempdbTests.cs new file mode 100644 index 0000000..9bc1102 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/TempdbTests.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.SqlClient; +using SQLInsight.Collectors.Instance; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; + +namespace SQLInsight.IntegrationTests; + +/// tempdb and the version store as the least-privilege agent reads them. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class TempdbTests(SqlServerFixture sql) +{ + [Fact] + public async Task Space_version_store_and_the_session_filling_tempdb_are_read() + { + await using var user = new SqlConnection(new SqlConnectionStringBuilder(sql.SaConnectionString) { ApplicationName = "Rapport" }.ConnectionString); + await user.OpenAsync(); + await using (var fill = new SqlCommand("CREATE TABLE #big (Id int, Pad char(4000)); INSERT #big SELECT TOP (3000) 1, 'x' FROM sys.all_objects a CROSS JOIN sys.all_objects b;", user)) + { + await fill.ExecuteNonQueryAsync(); + } + + await using var session = await AgentSession.OpenAsync(sql); + var first = await session.CollectAsync(new TempdbCollector(sessionMegabytes: 1)); + var second = await session.CollectAsync(new TempdbCollector(sessionMegabytes: 1), first); + + Assert.True(first.SizeMegabytes > 0); + Assert.InRange(first.FreeMegabytes, 0, first.SizeMegabytes); + Assert.True(first.VersionStoreMegabytes >= 0); + var heavy = Assert.Single(first.TopSessions, s => s.ProgramName == "Rapport"); + Assert.True(heavy.UsedMegabytes >= 10); + Assert.Equal(2, second.VersionStoreHistory.Count); + Assert.Equal(first.HeavySessionsSince[heavy.Key], second.HeavySessionsSince[heavy.Key]); // the start is kept + } + + [Fact] + public async Task Instance_page_and_api_show_tempdb_with_logins_left_out() + { + var database = "SQLInsightTmp_" + Guid.NewGuid().ToString("N")[..8]; + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];"); + try + { + var repository = new InsightRepository(sql.DatabaseConnectionString(database)); + await repository.MigrateAsync(); + var now = DateTimeOffset.UtcNow; + var id = await repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo + { + ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard, + }, "SqlClient", false, now); + await repository.SaveCollectedAsync(id, "tempdb", new TempdbSnapshot + { + CapturedAt = now, SizeMegabytes = 8192, FreeMegabytes = 1024, VersionStoreMegabytes = 2048, + TopSessions = [new TempdbSession(70, now.AddHours(-1), 3000, @"KUNDE\anne", "SSMS", "PC01", "running")], + VersionGenerators = [new VersionGenerator("AXDB", 1800)], + }); + + await using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database)); + b.UseSetting("SQLInsight:Web:Authentication", "None"); + }); + var client = factory.CreateClient(); + var page = await client.GetStringAsync("/instanser/prod-sql01"); + var api = await client.GetStringAsync("/api/v1/instances/prod-sql01/tempdb"); + + Assert.Contains("tempdb lige nu", page, StringComparison.Ordinal); + Assert.Contains("AXDB", page, StringComparison.Ordinal); + Assert.Contains("Version store (MB)", page, StringComparison.Ordinal); // the graph with its explanation + Assert.Contains("\"versionStoreMegabytes\":2048", api, StringComparison.Ordinal); + Assert.DoesNotContain("anne", api, StringComparison.Ordinal); + Assert.Single(await repository.GetMetricSamplesAsync(id, Metrics.VersionStoreMegabytes, now.AddMinutes(-5), now.AddMinutes(5))); + } + finally + { + await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];"); + } + } +} diff --git a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs index 7c72d28..7fe71e9 100644 --- a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs +++ b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs @@ -18,6 +18,6 @@ public class RuleCatalogTests [Fact] public void Rule_count() { - Assert.Equal(75, BuiltInRules.Create().Count); + Assert.Equal(78, BuiltInRules.Create().Count); } } diff --git a/tests/SQLInsight.Rules.Tests/TempdbRuleTests.cs b/tests/SQLInsight.Rules.Tests/TempdbRuleTests.cs new file mode 100644 index 0000000..005ace5 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/TempdbRuleTests.cs @@ -0,0 +1,51 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Rules.Engine; +using SQLInsight.Rules.Instance; +using static SQLInsight.Rules.Tests.TestInstances; + +namespace SQLInsight.Rules.Tests; + +public class TempdbRuleTests +{ + private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context)); + + private static TempdbSnapshot Tempdb(double versionStore = 100, double free = 5000, bool canGrow = true, IReadOnlyList? history = null) => new() + { + CapturedAt = Now, SizeMegabytes = 10000, FreeMegabytes = free, VersionStoreMegabytes = versionStore, CanGrow = canGrow, + VersionStoreHistory = history ?? [versionStore], + OldestSnapshotTransaction = new SnapshotTransaction(61, 7200, @"KUNDE\anne", "Ax32.exe"), + VersionGenerators = [new VersionGenerator("AXDB", 900)], + }; + + [Fact] + public void A_large_or_steadily_growing_version_store_is_found() + { + Assert.Equal(RuleOutcome.Passed, Run(new VersionStoreRule(), With(Tempdb(500))).Outcome); + var large = Assert.Single(Run(new VersionStoreRule(), With(Tempdb(2000))).Findings); + Assert.Contains(large.Evidence, e => e.Name == "Databaser, der laver versioner" && e.Value.Contains("AXDB", StringComparison.Ordinal)); + Assert.True(large.Evidence.Single(e => e.Name == "Dens login og program").Sensitive); + Assert.Equal(RuleOutcome.Findings, Run(new VersionStoreRule(), With(Tempdb(600, history: [100, 200, 300, 400, 500, 600]))).Outcome); + Assert.False(TempdbAnalysis.IsGrowing([100, 200, 150, 400, 500, 600])); + } + + [Theory] + [InlineData(500, false, RuleOutcome.Findings)] + [InlineData(500, true, RuleOutcome.Passed)] // it can still grow + [InlineData(2000, false, RuleOutcome.Passed)] // 20 % free + public void Tempdb_nearly_full_only_when_it_cannot_grow(double free, bool canGrow, RuleOutcome expected) => + Assert.Equal(expected, Run(new TempdbFullRule(), With(Tempdb(free: free, canGrow: canGrow))).Outcome); + + [Fact] + public void A_session_heavy_for_fifteen_minutes_is_found() + { + var session = new TempdbSession(70, Now.AddHours(-1), 3000, "anne", "SSMS", "PC01", "running"); + var first = TempdbAnalysis.Advance(null, Tempdb() with { CapturedAt = Now.AddMinutes(-20), TopSessions = [session] }); + var now = TempdbAnalysis.Advance(first, Tempdb() with { TopSessions = [session] }); + + Assert.Equal(RuleOutcome.Passed, Run(new TempdbSessionRule(), With(first)).Outcome); + var finding = Assert.Single(Run(new TempdbSessionRule(), With(now)).Findings); + Assert.Equal(Severity.Medium, finding.Severity); + Assert.True(finding.Evidence.Single(e => e.Name == "Login").Sensitive); + } +}