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) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 10:42:38 +02:00
parent 6f7839d011
commit 4d18d20ab0
18 changed files with 549 additions and 4 deletions

View file

@ -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).
---

View file

@ -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 |

View file

@ -28,6 +28,8 @@ public sealed class AgentOptions
public ActivityOptions Activity { get; set; } = new();
public TempdbOptions Tempdb { get; set; } = new();
public IList<MonitoredInstance> Instances { get; } = [];
}
@ -110,3 +112,13 @@ public sealed class ActivityOptions
/// <summary>A user statement running longer than this (minutes) is a finding (SQLI-REQ-001).</summary>
public int RequestMinutes { get; set; } = 15;
}
/// <summary>tempdb and the version store.</summary>
public sealed class TempdbOptions
{
/// <summary>The version store above this (MB) is a finding (SQLI-TEMP-001).</summary>
public int VersionStoreMegabytes { get; set; } = 1024;
/// <summary>A session using more tempdb than this (MB) for 15 minutes is a finding (SQLI-TEMP-003).</summary>
public int SessionMegabytes { get; set; } = 1024;
}

View file

@ -252,6 +252,12 @@ public sealed partial class InstanceMonitor(
return await repository.GetSnapshotAsync<Core.Data.ErrorLogSnapshot>(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<Core.Data.TempdbSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
if (collector is not Collectors.Concurrency.DeadlockCollector)
{
return null;

View file

@ -33,7 +33,8 @@ public sealed partial class MonitoringService(
var intervals = new Dictionary<string, TimeSpan>(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)

View file

@ -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
/// <param name="TransactionMinutes">An open transaction older than this is a finding.</param>
/// <param name="TransactionLogMegabytes">A transaction holding more log than this is a finding.</param>
/// <param name="RequestMinutes">A statement running longer than this is a finding.</param>
/// <param name="VersionStoreMegabytes">The version store above this is a finding.</param>
/// <param name="TempdbSessionMegabytes">A session using more tempdb than this for 15 minutes is a finding.</param>
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);

View file

@ -0,0 +1,91 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// 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+.
/// </summary>
public sealed class TempdbCollector(int versionStoreMegabytes = 1024, int sessionMegabytes = 1024) : Collector<TempdbSnapshot>
{
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<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<TempdbSnapshot> 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,
});
}
}

View file

@ -0,0 +1,81 @@
namespace SQLInsight.Core.Data;
/// <summary>A session using tempdb (user objects plus internal objects: sorts, hash tables, spools). Login and program are sensitive.</summary>
/// <param name="Since">When the session logged in; with <see cref="SessionId"/> it identifies the session across samples.</param>
public sealed record TempdbSession(int SessionId, DateTimeOffset Since, double UsedMegabytes, string? LoginName, string? ProgramName, string? HostName, string? Status)
{
public string Key => $"{SessionId}|{Since.UtcTicks}";
}
/// <summary>The oldest open snapshot transaction: it keeps every row version made since it began.</summary>
public sealed record SnapshotTransaction(int SessionId, long ElapsedSeconds, string? LoginName, string? ProgramName);
/// <summary>Version store use per database (from sys.dm_tran_top_version_generators).</summary>
public sealed record VersionGenerator(string DatabaseName, double Megabytes);
/// <summary>tempdb space and the version store, every 5 minutes.</summary>
public sealed record TempdbSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The size of tempdb's data files now.</summary>
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; }
/// <summary>Whether a data file can still grow (autogrowth on and not at its maximum size).</summary>
public bool CanGrow { get; init; } = true;
public IReadOnlyList<TempdbSession> TopSessions { get; init; } = [];
public SnapshotTransaction? OldestSnapshotTransaction { get; init; }
public IReadOnlyList<VersionGenerator> VersionGenerators { get; init; } = [];
/// <summary>Version store sizes of the latest samples (MB), oldest first: the trend.</summary>
public IReadOnlyList<double> VersionStoreHistory { get; init; } = [];
/// <summary>Since when each session has been above <see cref="SessionThresholdMegabytes"/>, by <see cref="TempdbSession.Key"/>.</summary>
public IReadOnlyDictionary<string, DateTimeOffset> HeavySessionsSince { get; init; } = new Dictionary<string, DateTimeOffset>();
/// <summary>The version store above this is a finding (SQLInsight:Tempdb:VersionStoreMegabytes).</summary>
public int VersionStoreThresholdMegabytes { get; init; } = 1024;
/// <summary>A session using more tempdb than this for <see cref="TempdbAnalysis.HeavySessionMinutes"/> is a finding.</summary>
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;
/// <summary>The version store has grown in each of the last six samples, by a quarter GB in all.</summary>
public static bool IsGrowing(IReadOnlyList<double> 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;
}
/// <summary>Carries the trend and the heavy sessions over from the previous sample.</summary>
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<string, DateTimeOffset>(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 };
}
}

View file

@ -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<MetricPoint> 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<MetricPoint> points)
{
var avg = Average(points);

View file

@ -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<TimeWindowAnalysis> LoadAsync(

View file

@ -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";
}

View file

@ -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(),

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var t = context.Data.Get<TempdbSnapshot>();
var large = t.VersionStoreMegabytes >= t.VersionStoreThresholdMegabytes;
var growing = TempdbAnalysis.IsGrowing(t.VersionStoreHistory);
if (!large && !growing)
{
return RuleEvaluation.Passed();
}
var evidence = new List<Evidence>
{
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"));
}
}
/// <summary>tempdb is nearly full and cannot grow: queries needing it will fail.</summary>
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<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var t = context.Data.Get<TempdbSnapshot>();
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"));
}
}
/// <summary>A session that has used a lot of tempdb for a long time.</summary>
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<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var t = context.Data.Get<TempdbSnapshot>();
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}")));
}
}

View file

@ -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<IResult> TempdbAsync(string name, InsightRepository repository, IOptions<WebOptions> 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<TempdbSnapshot>(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<IResult> ErrorLogAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)

View file

@ -136,6 +136,22 @@ else
<LongOperationTable Operations="_operations" Empty="Ingen backup, restore, DBCC eller lignende med fremskridt de seneste 24 timer." />
</section>
<section class="card" id="tempdb">
<div class="card-head"><h2>tempdb lige nu</h2><span class="muted">@(_tempdb is null ? "endnu ikke målt" : "målt " + Display.Ago(_tempdb.CapturedAt.UtcDateTime))</span></div>
@if (_tempdb is { } td)
{
<table class="kv">
<tbody>
<tr><th>Størrelse / fri</th><td class="@(!td.CanGrow && td.FreePercent < 10 ? "bad" : "")">@Display.Number(td.SizeMegabytes) MB / @Display.Number(td.FreeMegabytes) MB (@Display.Number(td.FreePercent) %)@(td.CanGrow ? "" : ", kan ikke vokse")</td></tr>
<tr><th>Version store</th><td class="@(td.VersionStoreMegabytes >= td.VersionStoreThresholdMegabytes ? "bad" : "")">@Display.Number(td.VersionStoreMegabytes) MB@(td.VersionGenerators.Count > 0 ? " · " + string.Join(", ", td.VersionGenerators.Take(3).Select(g => $"{g.DatabaseName} {Display.Number(g.Megabytes)} MB")) : "")</td></tr>
<tr><th>Brugerobjekter / interne</th><td>@Display.Number(td.UserObjectsMegabytes) MB / @Display.Number(td.InternalObjectsMegabytes) MB</td></tr>
<tr><th>Ældste snapshot-transaktion</th><td>@(td.OldestSnapshotTransaction is { } o ? $"session {o.SessionId}, {TransactionTable.Duration(TimeSpan.FromSeconds(o.ElapsedSeconds))}" : "ingen")</td></tr>
<tr><th>Største forbrugere</th><td>@(td.TopSessions.Count == 0 ? "–" : string.Join(", ", td.TopSessions.Take(5).Select(s => $"session {s.SessionId} {Display.Number(s.UsedMegabytes)} MB")))</td></tr>
</tbody>
</table>
}
</section>
<section class="card" id="aktivitet">
<div class="card-head"><h2>Lige nu: åbne transaktioner og lange sætninger</h2><span class="muted">@(_activity is null ? "endnu ikke målt" : "målt " + Display.Ago(_activity.CapturedAt.UtcDateTime))</span></div>
@if (_activity is not { } activity || activity.Transactions.Count == 0 && activity.LongRequests.Count == 0)
@ -303,6 +319,7 @@ else
private IReadOnlyList<StoredBlocking> _blocking = [];
private IReadOnlyList<StoredLongOperation> _operations = [];
private ActivitySnapshot? _activity;
private TempdbSnapshot? _tempdb;
private IReadOnlyList<StoredDeadlock> _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<ActivitySnapshot>(id, "activity");
_tempdb = await Repository.GetSnapshotAsync<TempdbSnapshot>(id, "tempdb");
_deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7));
}

View file

@ -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;
/// <summary>tempdb and the version store as the least-privilege agent reads them.</summary>
[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<TempdbSnapshot>(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<Program>().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}];");
}
}
}

View file

@ -18,6 +18,6 @@ public class RuleCatalogTests
[Fact]
public void Rule_count()
{
Assert.Equal(75, BuiltInRules.Create().Count);
Assert.Equal(78, BuiltInRules.Create().Count);
}
}

View file

@ -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<double>? 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);
}
}