Some checks are pending
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
CI / Build og unit tests (push) Waiting to run
- Managed Instance reports 12.0.2000.8 but is evergreen: treated as the newest engine with all capabilities; new SelfManagedInfrastructure capability so backup, CHECKDB, files, tempdb, memory, patch level and lifecycle rules report 'handled by Azure' instead of false findings - Microsoft Entra ID login (managed identity, service principal, default credential chain) via SqlConnection.AccessTokenCallback - Least-privilege script recognizes Managed Instance; deadlock collector falls back to the ring buffer when event files cannot be read - Web UI shows the right release for Managed Instance - README and installation docs: notifications and Managed Instance - Catalog test: 61 rules with unique, well-formed ids Integration suite green on SQL Server 2017 and 2022. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
510 lines
27 KiB
C#
510 lines
27 KiB
C#
using System.Globalization;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.Rules.Databases;
|
|
|
|
internal static class Databases
|
|
{
|
|
public static IEnumerable<DatabaseInfo> Online(RuleContext context) =>
|
|
context.Data.Get<DatabaseSnapshot>().Databases.Where(d => d.State == "ONLINE");
|
|
|
|
public static string Subject(DatabaseInfo database) => $"database: {database.Name}";
|
|
|
|
public static TimeSpan Age(RuleContext context, DateTime? time) => context.Now.UtcDateTime - (time ?? DateTime.MinValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A database setting with a value that is a known problem.
|
|
/// </summary>
|
|
public sealed class DatabaseSettingRule(
|
|
string id,
|
|
string title,
|
|
Func<DatabaseInfo, bool> isProblem,
|
|
Severity severity,
|
|
Func<DatabaseInfo, string> findingTitle,
|
|
string explanation,
|
|
string recommendation,
|
|
Func<DatabaseInfo, string> fixScript,
|
|
Reference reference) : Rule
|
|
{
|
|
public override string Id => id;
|
|
public override string Title => title;
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context) =>
|
|
Result(Databases.Online(context).Where(isProblem).Select(d => CreateFinding(
|
|
severity,
|
|
findingTitle(d),
|
|
explanation,
|
|
recommendation,
|
|
subject: Databases.Subject(d),
|
|
fixScript: fixScript(d),
|
|
references: reference)));
|
|
}
|
|
|
|
public static class SimpleDatabaseRules
|
|
{
|
|
private static string Name(DatabaseInfo d) => "[" + d.Name.Replace("]", "]]", StringComparison.Ordinal) + "]";
|
|
|
|
public static IEnumerable<IRule> Create() =>
|
|
[
|
|
new DatabaseSettingRule(
|
|
"SQLI-DB-002", "AUTO_SHRINK er slået til",
|
|
d => d.IsAutoShrink, Severity.High,
|
|
d => $"AUTO_SHRINK er slået til på {d.Name}",
|
|
"Auto shrink flytter sider rundt for at frigive plads, hvilket fragmenterer indexes fuldstændigt og koster "
|
|
+ "CPU og I/O. Filerne vokser bagefter igen med autogrowth, så cyklussen gentager sig.",
|
|
"Slå AUTO_SHRINK fra. Hvis der reelt skal frigives plads, gøres det én gang manuelt og efterfulgt af index-vedligehold.",
|
|
d => $"ALTER DATABASE {Name(d)} SET AUTO_SHRINK OFF;",
|
|
Docs.Learn("AUTO_SHRINK", "relational-databases/policy-based-management/set-the-auto-shrink-database-option-to-off")),
|
|
|
|
new DatabaseSettingRule(
|
|
"SQLI-DB-003", "AUTO_CLOSE er slået til",
|
|
d => d.IsAutoClose, Severity.Medium,
|
|
d => $"AUTO_CLOSE er slået til på {d.Name}",
|
|
"Databasen lukkes, når den sidste forbindelse slipper den. Næste forbindelse skal åbne filerne og genopbygge "
|
|
+ "cachen, og plan cache for databasen tømmes hver gang. Det giver ujævne svartider og mange logbeskeder.",
|
|
"Slå AUTO_CLOSE fra.",
|
|
d => $"ALTER DATABASE {Name(d)} SET AUTO_CLOSE OFF;",
|
|
Docs.Learn("AUTO_CLOSE", "relational-databases/policy-based-management/set-the-auto-close-database-option-to-off")),
|
|
|
|
new DatabaseSettingRule(
|
|
"SQLI-DB-004", "Sidekontrol er ikke CHECKSUM",
|
|
d => d.PageVerify is not null && d.PageVerify != "CHECKSUM", Severity.Medium,
|
|
d => $"{d.Name} bruger PAGE_VERIFY {d.PageVerify}",
|
|
"Med CHECKSUM beregner SQL Server en kontrolsum for hver side, der skrives, og opdager dermed korruption fra disk, "
|
|
+ "controller eller driver, så snart siden læses. TORN_PAGE_DETECTION fanger kun en brøkdel, NONE intet.",
|
|
"Skift til CHECKSUM. Det gælder kun sider, der skrives efterfølgende, så kør også DBCC CHECKDB regelmæssigt.",
|
|
d => $"ALTER DATABASE {Name(d)} SET PAGE_VERIFY CHECKSUM;",
|
|
Docs.Learn("ALTER DATABASE SET options", "t-sql/statements/alter-database-transact-sql-set-options")),
|
|
|
|
new DatabaseSettingRule(
|
|
"SQLI-DB-005", "TRUSTWORTHY er slået til",
|
|
d => d.IsTrustworthy && !string.Equals(d.Name, "msdb", StringComparison.OrdinalIgnoreCase), Severity.Medium,
|
|
d => $"TRUSTWORTHY er slået til på {d.Name}",
|
|
"TRUSTWORTHY får SQL Server til at stole på kode i databasen, når den efterligner (impersonerer) brugere på "
|
|
+ "serverniveau. Ejes databasen af en sysadmin, kan en bruger med db_owner blive sysadmin.",
|
|
"Slå TRUSTWORTHY fra, og brug certifikat- eller modulsignering til de få moduler, der har brug for adgang uden for databasen.",
|
|
d => $"ALTER DATABASE {Name(d)} SET TRUSTWORTHY OFF;",
|
|
Docs.Learn("TRUSTWORTHY database property", "relational-databases/security/trustworthy-database-property")),
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Databases that are not usable.
|
|
/// </summary>
|
|
public sealed class DatabaseStateRule : Rule
|
|
{
|
|
public override string Id => "SQLI-DB-001";
|
|
public override string Title => "Database er ikke tilgængelig";
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
return Result(context.Data.Get<DatabaseSnapshot>().Databases
|
|
.Where(d => d.State is "SUSPECT" or "RECOVERY_PENDING" or "EMERGENCY" or "OFFLINE")
|
|
.Select(d => CreateFinding(
|
|
d.State == "OFFLINE" ? Severity.Low : Severity.Critical,
|
|
$"{d.Name} er {d.State}",
|
|
d.State switch
|
|
{
|
|
"SUSPECT" => "SQL Server kunne ikke gennemføre recovery, typisk på grund af korruption eller en manglende/ødelagt fil. Databasen kan ikke bruges.",
|
|
"RECOVERY_PENDING" => "Recovery kunne ikke starte, typisk fordi en fil mangler, er låst eller disken er fuld. Databasen kan ikke bruges.",
|
|
"EMERGENCY" => "Databasen er sat i EMERGENCY-tilstand, normalt som led i en nødreparation. Kun sysadmin kan læse den.",
|
|
_ => "Databasen er taget offline. Det kan være bevidst, men den bliver hverken backet op eller tjekket.",
|
|
},
|
|
d.State == "OFFLINE"
|
|
? "Bekræft, at det er bevidst. Ellers: sæt den online eller fjern den."
|
|
: "Se SQL Server error log for årsagen. Gendan fra backup, hvis filerne er beskadigede. Kør ikke DBCC CHECKDB med REPAIR_ALLOW_DATA_LOSS uden at forstå konsekvensen: den sletter data.",
|
|
[new Evidence("Tilstand", d.State)],
|
|
subject: Databases.Subject(d))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compatibility level below the engine's native level.
|
|
/// </summary>
|
|
public sealed class CompatibilityLevelRule : Rule
|
|
{
|
|
public override string Id => "SQLI-DB-006";
|
|
public override string Title => "Database kører med et ældre compatibility level";
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var native = context.Instance.Version.Major * 10;
|
|
return Result(Databases.Online(context)
|
|
.Where(d => !d.IsSystemDatabase && d.CompatibilityLevel < native)
|
|
.Select(d => CreateFinding(
|
|
Severity.Low,
|
|
$"{d.Name} kører med compatibility level {d.CompatibilityLevel} (instansen understøtter {native})",
|
|
"Databasen bruger query optimizer og T-SQL-adfærd fra en ældre version og går glip af forbedringer som "
|
|
+ "adaptive joins, batch mode på rowstore og bedre kardinalitetsestimering.",
|
|
"Hæv compatibility level kontrolleret: slå Query Store til, saml en baseline, hæv niveauet, og brug Query Store til "
|
|
+ "at finde og låse eventuelle regredierede planer.",
|
|
[new Evidence("Compatibility level", d.CompatibilityLevel.ToString(CultureInfo.InvariantCulture)),
|
|
new Evidence("Instansens niveau", native.ToString(CultureInfo.InvariantCulture))],
|
|
subject: Databases.Subject(d),
|
|
references: Docs.Learn("Compatibility level", "relational-databases/databases/view-or-change-the-compatibility-level-of-a-database"))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Databases without a recent full backup.
|
|
/// </summary>
|
|
public sealed class FullBackupRule : Rule
|
|
{
|
|
public static readonly TimeSpan MaxAge = TimeSpan.FromDays(7);
|
|
|
|
public override string Id => "SQLI-DB-007";
|
|
public override string Title => "Database mangler en nylig fuld backup";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var findings = new List<Finding>();
|
|
foreach (var d in Databases.Online(context))
|
|
{
|
|
if (d.IsReplicaOrMirror || string.Equals(d.Name, "model", StringComparison.OrdinalIgnoreCase)
|
|
|| Databases.Age(context, d.CreateDate) < TimeSpan.FromDays(1))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var age = Databases.Age(context, d.LastFullBackup);
|
|
if (d.LastFullBackup is not null && age <= MaxAge)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var severity = d.LastFullBackup is null ? Severity.Critical : Severity.High;
|
|
var agNote = string.Empty;
|
|
if (d.IsInAvailabilityGroup)
|
|
{
|
|
severity = Severity.Medium;
|
|
agNote = " Databasen er i en availability group, så backups kan være taget på en anden replika; tjek den.";
|
|
}
|
|
|
|
findings.Add(CreateFinding(
|
|
severity,
|
|
d.LastFullBackup is null ? $"{d.Name} har aldrig haft en fuld backup" : $"{d.Name}: seneste fulde backup er {Format.Age(age)} gammel",
|
|
"Uden en nylig fuld backup kan databasen ikke gendannes efter en diskfejl, korruption eller en fejlagtig sletning, "
|
|
+ "og hver differentiel og log-backup er afhængig af den." + agNote,
|
|
"Planlæg fulde backups mindst ugentligt (dagligt for vigtige databaser) til et andet medie end databasefilerne, "
|
|
+ "og test en gendannelse jævnligt. En backup, der aldrig er testet, er ikke en backup.",
|
|
[new Evidence("Seneste fulde backup", Format.Date(d.LastFullBackup)), new Evidence("Recovery model", d.RecoveryModel)],
|
|
subject: Databases.Subject(d),
|
|
references: Docs.Learn("Backup and restore", "relational-databases/backup-restore/back-up-and-restore-of-sql-server-databases")));
|
|
}
|
|
|
|
return Result(findings);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// FULL/BULK_LOGGED databases without log backups: the log grows forever and point-in-time recovery is lost.
|
|
/// </summary>
|
|
public sealed class LogBackupRule : Rule
|
|
{
|
|
public static readonly TimeSpan MaxAge = TimeSpan.FromHours(24);
|
|
|
|
public override string Id => "SQLI-DB-008";
|
|
public override string Title => "Database i FULL recovery uden log-backups";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
return Result(Databases.Online(context)
|
|
.Where(d => d.RecoveryModel is "FULL" or "BULK_LOGGED" && !d.IsReplicaOrMirror && !d.IsReadOnly
|
|
&& !string.Equals(d.Name, "model", StringComparison.OrdinalIgnoreCase)
|
|
&& d.LastFullBackup is not null // Before the first full backup the log truncates as in SIMPLE.
|
|
&& (d.LastLogBackup is null || Databases.Age(context, d.LastLogBackup) > MaxAge))
|
|
.Select(d => CreateFinding(
|
|
d.IsInAvailabilityGroup ? Severity.Medium : Severity.High,
|
|
d.LastLogBackup is null
|
|
? $"{d.Name} er i {d.RecoveryModel} recovery, men har aldrig haft en log-backup"
|
|
: $"{d.Name}: seneste log-backup er {Format.Age(Databases.Age(context, d.LastLogBackup))} gammel",
|
|
$"I {d.RecoveryModel} recovery frigives plads i transaktionsloggen først, når loggen er backet op. Uden log-backups "
|
|
+ "vokser loggen, til disken er fuld, og al data siden seneste fulde backup går tabt ved et nedbrud.",
|
|
"Tag log-backups med et interval, der passer til det datatab, forretningen kan tåle (typisk 5-15 minutter). "
|
|
+ "Er point-in-time restore ikke nødvendigt, så skift til SIMPLE recovery i stedet.",
|
|
[new Evidence("Recovery model", d.RecoveryModel), new Evidence("Seneste log-backup", Format.Date(d.LastLogBackup)),
|
|
new Evidence("Seneste fulde backup", Format.Date(d.LastFullBackup))],
|
|
subject: Databases.Subject(d),
|
|
references: Docs.Learn("Transaction log backups", "relational-databases/backup-restore/transaction-log-backups-sql-server"))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// DBCC CHECKDB not run recently.
|
|
/// </summary>
|
|
public sealed class CheckDbRule : Rule
|
|
{
|
|
public static readonly TimeSpan MaxAge = TimeSpan.FromDays(7);
|
|
|
|
public override string Id => "SQLI-DB-009";
|
|
public override string Title => "DBCC CHECKDB er ikke kørt for nylig";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LastGoodCheckDbTime];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var findings = new List<Finding>();
|
|
foreach (var d in Databases.Online(context))
|
|
{
|
|
if (d.LastGoodCheckDb is not { } last || Databases.Age(context, d.CreateDate) < MaxAge)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var never = last.Year <= 1900;
|
|
var age = Databases.Age(context, last);
|
|
if (!never && age <= MaxAge)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
findings.Add(CreateFinding(
|
|
never || age > TimeSpan.FromDays(30) ? Severity.High : Severity.Medium,
|
|
never ? $"DBCC CHECKDB har aldrig kørt fejlfrit på {d.Name}" : $"{d.Name}: seneste fejlfri DBCC CHECKDB er {Format.Age(age)} gammel",
|
|
"Korruption opdages først, når en beskadiget side læses, og kan brede sig til backups i ugevis. Jo længere siden "
|
|
+ "seneste CHECKDB, jo længere tilbage skal man måske gendanne, og jo større datatab.",
|
|
"Kør DBCC CHECKDB WITH NO_INFOMSGS, ALL_ERRORMSGS mindst ugentligt, gerne på en gendannet kopi, hvis vinduet er for kort. "
|
|
+ "Behold backups længere end intervallet mellem to CHECKDB.",
|
|
[new Evidence("Seneste fejlfri CHECKDB", never ? "aldrig" : Format.Date(last))],
|
|
subject: Databases.Subject(d),
|
|
references: Docs.Learn("DBCC CHECKDB", "t-sql/database-console-commands/dbcc-checkdb-transact-sql")));
|
|
}
|
|
|
|
return Result(findings);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pages SQL Server has recorded as corrupt.
|
|
/// </summary>
|
|
public sealed class SuspectPagesRule : Rule
|
|
{
|
|
public override string Id => "SQLI-DB-010";
|
|
public override string Title => "Korrupte sider registreret i suspect_pages";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
return Result(context.Data.Get<SuspectPageSnapshot>().Pages
|
|
.Where(p => p.IsUnresolved)
|
|
.GroupBy(p => p.DatabaseName, StringComparer.OrdinalIgnoreCase)
|
|
.Select(g => CreateFinding(
|
|
Severity.Critical,
|
|
$"{g.Key}: {g.Count()} korrupte sider",
|
|
"SQL Server har mødt sider med I/O-fejl, forkert kontrolsum eller torn pages (fejl 823/824/825). Det er næsten "
|
|
+ "altid et tegn på fejl i lagersystemet, og mere korruption følger ofte efter.",
|
|
"Kør DBCC CHECKDB straks for at se omfanget. Gendan de berørte sider eller hele databasen fra backup (page restore er "
|
|
+ "muligt i FULL recovery). Undersøg disk, controller og drivere, og tjek Windows' systemlog.",
|
|
g.Take(20).Select(p => new Evidence($"Fil {p.FileId}, side {p.PageId}", $"hændelse {p.EventType}, {p.ErrorCount} fejl, senest {Format.Date(p.LastUpdate)}")),
|
|
subject: $"database: {g.Key}",
|
|
references: Docs.Learn("suspect_pages", "relational-databases/backup-restore/manage-the-suspect-pages-table-sql-server"))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Too many virtual log files slow down recovery, restores and log backups.
|
|
/// </summary>
|
|
public sealed class VirtualLogFileRule : Rule
|
|
{
|
|
public const int Threshold = 1000;
|
|
|
|
public override string Id => "SQLI-DB-011";
|
|
public override string Title => "Transaktionsloggen har for mange VLF'er";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LogInfoDmv];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
return Result(Databases.Online(context)
|
|
.Where(d => d.VirtualLogFileCount > Threshold)
|
|
.Select(d => CreateFinding(
|
|
d.VirtualLogFileCount > 10 * Threshold ? Severity.High : Severity.Medium,
|
|
$"{d.Name} har {Format.Number(d.VirtualLogFileCount!.Value)} VLF'er i transaktionsloggen",
|
|
"Loggen er vokset i mange små trin. Hver vækst opretter nye virtuelle logfiler, og mange VLF'er gør crash recovery, "
|
|
+ "restore, log-backups, replikering og AG-synkronisering markant langsommere.",
|
|
"Tag en log-backup (eller CHECKPOINT i SIMPLE), shrink logfilen, og lad den vokse igen i få, store trin til den "
|
|
+ "nødvendige størrelse. Sæt en fast autogrowth på fx 1024 MB.",
|
|
[new Evidence("VLF'er", Format.Number(d.VirtualLogFileCount!.Value))],
|
|
subject: Databases.Subject(d),
|
|
references: Docs.Learn("Transaction log architecture", "relational-databases/sql-server-transaction-log-architecture-and-management-guide"))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Autogrowth settings that cause many or unpredictable growths.
|
|
/// </summary>
|
|
public sealed class FileGrowthRule : Rule
|
|
{
|
|
public override string Id => "SQLI-DB-012";
|
|
public override string Title => "Uhensigtsmæssig autogrowth";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var findings = new List<Finding>();
|
|
foreach (var d in Databases.Online(context))
|
|
{
|
|
foreach (var f in d.Files.Where(f => f.Growth > 0 && f.SizeMb >= 1024))
|
|
{
|
|
string? problem = null;
|
|
if (f.IsPercentGrowth)
|
|
{
|
|
problem = $"vokser med {f.Growth} %, dvs. ca. {Format.Number(f.SizeMb * f.Growth / 100)} MB næste gang og mere hver gang";
|
|
}
|
|
else if (f.Growth < 64)
|
|
{
|
|
problem = $"vokser kun {f.Growth} MB ad gangen på en fil på {Format.Number(f.SizeMb)} MB";
|
|
}
|
|
|
|
if (problem is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
findings.Add(CreateFinding(
|
|
Severity.Low,
|
|
$"{d.Name}/{f.LogicalName} {problem}",
|
|
"Procentvis vækst bliver større og langsommere, jo større filen er. Små faste trin giver mange vækster, "
|
|
+ "fragmentering og, for logfiler, mange VLF'er. Logfiler (og datafiler uden instant file initialization) "
|
|
+ "skal nulstilles ved vækst, så brugerne venter imens.",
|
|
"Sæt en fast vækst i MB, fx 256-1024 MB for datafiler og 512-1024 MB for logfiler, og størrelsesplanlæg "
|
|
+ "filerne, så autogrowth kun er et sikkerhedsnet.",
|
|
[new Evidence("Fil", f.PhysicalName), new Evidence("Størrelse (MB)", Format.Number(f.SizeMb)),
|
|
new Evidence("Autogrowth", f.IsPercentGrowth ? $"{f.Growth} %" : $"{f.Growth} MB")],
|
|
subject: $"file: {d.Name}/{f.LogicalName}",
|
|
fixScript: $"ALTER DATABASE [{d.Name.Replace("]", "]]", StringComparison.Ordinal)}] MODIFY FILE (NAME = N'{f.LogicalName.Replace("'", "''", StringComparison.Ordinal)}', FILEGROWTH = {(f.Type == "LOG" ? 1024 : 512)}MB);"));
|
|
}
|
|
}
|
|
|
|
return Result(findings);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Files that will soon hit their maximum size.
|
|
/// </summary>
|
|
public sealed class FileMaxSizeRule : Rule
|
|
{
|
|
// The log file default "max" of 2 TB is effectively unlimited.
|
|
private const long EffectivelyUnlimitedMb = 2 * 1024 * 1024;
|
|
|
|
public override string Id => "SQLI-DB-013";
|
|
public override string Title => "Databasefil nærmer sig sin maksimale størrelse";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
return Result(Databases.Online(context).SelectMany(d => d.Files
|
|
.Where(f => f.MaxSizeMb > 0 && f.MaxSizeMb < EffectivelyUnlimitedMb && f.SizeMb >= f.MaxSizeMb * 0.9)
|
|
.Select(f => CreateFinding(
|
|
f.SizeMb >= f.MaxSizeMb ? Severity.Critical : Severity.High,
|
|
$"{d.Name}/{f.LogicalName} er {Format.Number(100.0 * f.SizeMb / f.MaxSizeMb)} % af sin maksimale størrelse",
|
|
"Filen kan ikke vokse ud over MAXSIZE. Når den er fuld, fejler alle ændringer (fejl 1105 for data, 9002 for log), "
|
|
+ "selvom der er plads på disken.",
|
|
"Hæv eller fjern MAXSIZE, eller frigør plads i filen. For logfiler: find ud af, hvad der forhindrer genbrug "
|
|
+ "(log_reuse_wait_desc i sys.databases).",
|
|
[new Evidence("Størrelse (MB)", Format.Number(f.SizeMb)), new Evidence("Maks. (MB)", Format.Number(f.MaxSizeMb))],
|
|
subject: $"file: {d.Name}/{f.LogicalName}"))));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query Store switched itself to read-only or error.
|
|
/// </summary>
|
|
public sealed class QueryStoreStateRule : Rule
|
|
{
|
|
public override string Id => "SQLI-DB-014";
|
|
public override string Title => "Query Store indsamler ikke længere data";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
return Result(Databases.Online(context)
|
|
.Where(d => d.QueryStore is { } qs && qs.DesiredState == "READ_WRITE" && qs.ActualState != "READ_WRITE")
|
|
.Select(d =>
|
|
{
|
|
var qs = d.QueryStore!;
|
|
var full = (qs.ReadOnlyReason & 65536) != 0;
|
|
return CreateFinding(
|
|
qs.ActualState == "ERROR" ? Severity.High : Severity.Medium,
|
|
$"Query Store på {d.Name} er {qs.ActualState}" + (full ? " (fuld)" : string.Empty),
|
|
"Query Store er sat til READ_WRITE, men er selv gået over i " + qs.ActualState + ". Der indsamles ingen nye planer "
|
|
+ "eller statistikker, så planregressioner kan hverken opdages eller rettes med forcerede planer.",
|
|
full
|
|
? "Hæv MAX_STORAGE_SIZE_MB, eller sænk opbevaringstiden (STALE_QUERY_THRESHOLD_DAYS), og sæt QUERY_CAPTURE_MODE = AUTO. Sæt derefter OPERATION_MODE = READ_WRITE igen."
|
|
: "Se readonly_reason i sys.database_query_store_options. Ved ERROR: kør sp_query_store_consistency_check eller slå Query Store fra og til.",
|
|
[new Evidence("Tilstand", $"ønsket {qs.DesiredState}, faktisk {qs.ActualState}"),
|
|
new Evidence("Størrelse (MB)", $"{qs.CurrentStorageMb} af {qs.MaxStorageMb}"),
|
|
new Evidence("readonly_reason", qs.ReadOnlyReason.ToString(CultureInfo.InvariantCulture))],
|
|
subject: Databases.Subject(d),
|
|
references: Docs.Learn("Query Store", "relational-databases/performance/monitoring-performance-by-using-the-query-store"));
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// tempdb with too few, or unevenly sized, data files.
|
|
/// </summary>
|
|
public sealed class TempdbFilesRule : Rule
|
|
{
|
|
public override string Id => "SQLI-DB-015";
|
|
public override string Title => "tempdb har for få eller ulige store datafiler";
|
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
|
|
|
public override RuleEvaluation Evaluate(RuleContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var files = context.Data.Get<DatabaseSnapshot>().TempdbFiles.Where(f => f.Type == "ROWS").ToList();
|
|
var cpus = context.Data.Get<ServerSnapshot>().LogicalCpuCount;
|
|
if (files.Count == 0)
|
|
{
|
|
return RuleEvaluation.Passed();
|
|
}
|
|
|
|
var recommended = Math.Min(8, cpus);
|
|
var findings = new List<Finding>();
|
|
var evidence = files.Select(f => new Evidence(f.LogicalName, $"{Format.Number(f.SizeMb)} MB, vækst {(f.IsPercentGrowth ? $"{f.Growth} %" : $"{f.Growth} MB")}")).ToList();
|
|
|
|
if (files.Count < recommended && files.Count < 4)
|
|
{
|
|
findings.Add(CreateFinding(
|
|
Severity.Medium,
|
|
$"tempdb har {files.Count} datafil(er) på en server med {cpus} logiske CPU'er",
|
|
"Hver datafil har egne allokeringssider (PFS, GAM, SGAM). Med få filer konkurrerer sessioner om de samme sider, "
|
|
+ "hvilket viser sig som PAGELATCH-ventetider på tempdb, når der oprettes mange midlertidige objekter.",
|
|
$"Brug {recommended} lige store datafiler (én pr. logisk CPU op til 8). Tilføj flere i trin af 4, hvis der stadig er contention.",
|
|
evidence,
|
|
subject: "database: tempdb",
|
|
references: Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")));
|
|
}
|
|
|
|
if (files.Count > 1 && (files.Select(f => f.SizeMb).Distinct().Count() > 1 || files.Select(f => (f.Growth, f.IsPercentGrowth)).Distinct().Count() > 1))
|
|
{
|
|
findings.Add(CreateFinding(
|
|
Severity.Low,
|
|
"tempdb's datafiler er ikke ens",
|
|
"SQL Server fordeler allokeringer efter ledig plads (proportional fill). Er filerne forskellige i størrelse eller "
|
|
+ "vækst, rammer det meste den største fil, og fordelen ved flere filer forsvinder.",
|
|
"Giv alle tempdb-datafiler samme størrelse og samme faste autogrowth.",
|
|
evidence,
|
|
subject: "database: tempdb",
|
|
references: Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")));
|
|
}
|
|
|
|
return Result(findings);
|
|
}
|
|
}
|