A rule whose topic applies but whose way of measuring needs a newer version or patch now gives the new outcome ManualCheck with concrete Danish guidance, instead of NotApplicable. It is marked explicitly per rule (IRule.ManualCheck) and only used when every missing capability is a measurement capability (InstanceCapabilities.IsMeasurement): CHECKDB status (SQLI-DB-009), VLFs (SQLI-DB-011), spills and memory grants (SQLI-PLAN-004/005) and deadlocks on 2005 (SQLI-DLK-001). Query Store, AG and Azure-managed topics stay "not relevant". Shown in a visible "Skal tjekkes manuelt" section on the instance page, a KPI on the overview, a "Tjek manuelt" column in the management report's controls, manualChecks in /api/v1/instances and manualCheck in rule-states; not counted as not measurable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
278 lines
11 KiB
C#
278 lines
11 KiB
C#
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Rules.Databases;
|
|
using SQLInsight.Rules.Engine;
|
|
using static SQLInsight.Rules.Tests.TestInstances;
|
|
|
|
namespace SQLInsight.Rules.Tests;
|
|
|
|
public class DatabaseRuleTests
|
|
{
|
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
|
|
|
private static RuleResult Run(IRule rule, params DatabaseInfo[] databases) => Run(rule, With(DatabaseList(databases)));
|
|
|
|
[Fact]
|
|
public void Healthy_database_passes_every_database_rule()
|
|
{
|
|
var rules = new IRule[]
|
|
{
|
|
new DatabaseStateRule(), new CompatibilityLevelRule(), new FullBackupRule(), new LogBackupRule(), new CheckDbRule(),
|
|
new VirtualLogFileRule(), new FileGrowthRule(), new FileMaxSizeRule(), new QueryStoreStateRule(),
|
|
}.Concat(SimpleDatabaseRules.Create());
|
|
|
|
foreach (var rule in rules)
|
|
{
|
|
Assert.Equal(RuleOutcome.Passed, Run(rule, Database()).Outcome);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("SUSPECT", Severity.Critical)]
|
|
[InlineData("RECOVERY_PENDING", Severity.Critical)]
|
|
[InlineData("OFFLINE", Severity.Low)]
|
|
public void Unusable_database_state(string state, Severity severity)
|
|
{
|
|
var finding = Assert.Single(Run(new DatabaseStateRule(), Database(change: d => d with { State = state })).Findings);
|
|
|
|
Assert.Equal(severity, finding.Severity);
|
|
Assert.Equal("database: Sales", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Settings_rules_skip_offline_databases()
|
|
{
|
|
var offline = Database(change: d => d with { State = "OFFLINE", IsAutoShrink = true });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-002"), offline).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("SQLI-DB-002")]
|
|
[InlineData("SQLI-DB-003")]
|
|
[InlineData("SQLI-DB-004")]
|
|
[InlineData("SQLI-DB-005")]
|
|
public void Simple_database_rules_find_and_script_the_fix(string id)
|
|
{
|
|
var bad = Database("My]Db", d => d with { IsAutoShrink = true, IsAutoClose = true, PageVerify = "TORN_PAGE_DETECTION", IsTrustworthy = true });
|
|
|
|
var finding = Assert.Single(Run(SimpleDatabaseRules.Create().Single(r => r.Id == id), bad).Findings);
|
|
|
|
Assert.StartsWith("ALTER DATABASE [My]]Db]", finding.FixScript, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Trustworthy_msdb_is_by_design()
|
|
{
|
|
var msdb = Database("msdb", d => d with { DatabaseId = 4, IsTrustworthy = true });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-005"), msdb).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Old_compatibility_level_is_reported_for_user_databases_only()
|
|
{
|
|
var user = Database(change: d => d with { CompatibilityLevel = 130 });
|
|
var system = Database("master", d => d with { DatabaseId = 1, CompatibilityLevel = 130 });
|
|
|
|
Assert.Single(Run(new CompatibilityLevelRule(), user, system).Findings);
|
|
}
|
|
|
|
[Fact]
|
|
public void Never_backed_up_is_critical()
|
|
{
|
|
var finding = Assert.Single(Run(new FullBackupRule(), Database(change: d => d with { LastFullBackup = null })).Findings);
|
|
|
|
Assert.Equal(Severity.Critical, finding.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Old_backup_is_high_but_medium_in_an_availability_group()
|
|
{
|
|
var old = Database(change: d => d with { LastFullBackup = Now.UtcDateTime.AddDays(-9) });
|
|
|
|
Assert.Equal(Severity.High, Assert.Single(Run(new FullBackupRule(), old).Findings).Severity);
|
|
Assert.Equal(Severity.Medium, Assert.Single(Run(new FullBackupRule(), old with { IsInAvailabilityGroup = true }).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Backup_rule_skips_new_databases_model_and_secondaries()
|
|
{
|
|
var created = Database(change: d => d with { LastFullBackup = null, CreateDate = Now.UtcDateTime.AddHours(-2) });
|
|
var model = Database("model", d => d with { DatabaseId = 3, LastFullBackup = null });
|
|
var secondary = Database(change: d => d with { LastFullBackup = null, IsReplicaOrMirror = true });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new FullBackupRule(), created, model, secondary).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("FULL", 30, RuleOutcome.Findings)]
|
|
[InlineData("FULL", 12, RuleOutcome.Passed)]
|
|
[InlineData("BULK_LOGGED", 30, RuleOutcome.Findings)]
|
|
[InlineData("SIMPLE", 30, RuleOutcome.Passed)]
|
|
public void Log_backups_in_full_recovery(string model, int hoursSinceLogBackup, RuleOutcome expected)
|
|
{
|
|
var db = Database(change: d => d with { RecoveryModel = model, LastLogBackup = Now.UtcDateTime.AddHours(-hoursSinceLogBackup) });
|
|
|
|
Assert.Equal(expected, Run(new LogBackupRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Full_recovery_without_any_full_backup_is_not_a_log_backup_problem()
|
|
{
|
|
var db = Database(change: d => d with { LastFullBackup = null, LastLogBackup = null });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new LogBackupRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckDb_never_run_is_high()
|
|
{
|
|
var db = Database(change: d => d with { LastGoodCheckDb = new DateTime(1900, 1, 1) });
|
|
|
|
var finding = Assert.Single(Run(new CheckDbRule(), db).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Contains("aldrig", finding.Title, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(10, Severity.Medium)]
|
|
[InlineData(40, Severity.High)]
|
|
public void CheckDb_age(int days, Severity severity)
|
|
{
|
|
var db = Database(change: d => d with { LastGoodCheckDb = Now.UtcDateTime.AddDays(-days) });
|
|
|
|
Assert.Equal(severity, Assert.Single(Run(new CheckDbRule(), db).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckDb_before_2016_sp2_must_be_checked_by_hand()
|
|
{
|
|
var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database())));
|
|
|
|
Assert.Equal(RuleOutcome.ManualCheck, result.Outcome);
|
|
Assert.Contains("tjek manuelt", result.Reason, StringComparison.Ordinal);
|
|
Assert.Contains("dbi_dbccLastKnownGood", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_store_before_2016_is_not_relevant_rather_than_a_manual_check() =>
|
|
Assert.Equal(RuleOutcome.NotApplicable, Run(new QueryStoreStateRule(), WithVersion("12.0.6024.0", DatabaseList(Database()))).Outcome);
|
|
|
|
[Fact]
|
|
public void Suspect_pages_ignore_repaired_pages()
|
|
{
|
|
var pages = new SuspectPageSnapshot(Now,
|
|
[
|
|
new SuspectPage("Sales", 1, 1234, 2, 1, Now.UtcDateTime),
|
|
new SuspectPage("Sales", 1, 5678, 3, 2, Now.UtcDateTime),
|
|
new SuspectPage("Hr", 1, 42, 5, 1, Now.UtcDateTime), // repaired
|
|
]);
|
|
|
|
var finding = Assert.Single(Run(new SuspectPagesRule(), With(pages)).Findings);
|
|
|
|
Assert.Equal(Severity.Critical, finding.Severity);
|
|
Assert.Equal("database: Sales", finding.Subject);
|
|
Assert.Equal(2, finding.Evidence.Count);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(999, RuleOutcome.Passed)]
|
|
[InlineData(1500, RuleOutcome.Findings)]
|
|
public void Vlf_threshold(int vlfs, RuleOutcome expected)
|
|
{
|
|
Assert.Equal(expected, Run(new VirtualLogFileRule(), Database(change: d => d with { VirtualLogFileCount = vlfs })).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void File_growth_flags_percent_and_tiny_fixed_growth_on_large_files()
|
|
{
|
|
static DatabaseFileInfo File(string name, long size, long growth, bool percent) => new()
|
|
{
|
|
LogicalName = name, Type = "ROWS", PhysicalName = name + ".mdf", SizeMb = size, Growth = growth, IsPercentGrowth = percent, MaxSizeMb = -1,
|
|
};
|
|
var db = Database(change: d => d with
|
|
{
|
|
Files =
|
|
[
|
|
File("percent", 50_000, 10, true),
|
|
File("tiny", 20_000, 1, false),
|
|
File("fine", 20_000, 512, false),
|
|
File("small-db", 100, 10, true), // too small to matter
|
|
File("fixed-size", 20_000, 0, false), // autogrowth off: deliberate
|
|
],
|
|
});
|
|
|
|
var findings = Run(new FileGrowthRule(), db).Findings;
|
|
|
|
Assert.Equal(["file: Sales/percent", "file: Sales/tiny"], findings.Select(f => f.Subject));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(950, 1000, Severity.High)]
|
|
[InlineData(1000, 1000, Severity.Critical)]
|
|
public void File_near_max_size(long size, long max, Severity severity)
|
|
{
|
|
var db = Database(change: d => d with
|
|
{
|
|
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = max }],
|
|
});
|
|
|
|
Assert.Equal(severity, Assert.Single(Run(new FileMaxSizeRule(), db).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Default_2tb_log_max_size_is_unlimited()
|
|
{
|
|
var db = Database(change: d => d with
|
|
{
|
|
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = 2_000_000, Growth = 64, IsPercentGrowth = false, MaxSizeMb = 2_097_152 }],
|
|
});
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new FileMaxSizeRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_store_that_switched_to_read_only_because_full()
|
|
{
|
|
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_WRITE", "READ_ONLY", 65536, 1000, 1000) });
|
|
|
|
var finding = Assert.Single(Run(new QueryStoreStateRule(), db).Findings);
|
|
|
|
Assert.Contains("fuld", finding.Title, StringComparison.Ordinal);
|
|
Assert.Contains("MAX_STORAGE_SIZE_MB", finding.Recommendation, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_store_deliberately_read_only_passes()
|
|
{
|
|
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_ONLY", "READ_ONLY", 0, 10, 1000) });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new QueryStoreStateRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tempdb_with_one_file_on_many_cpus()
|
|
{
|
|
var snapshot = DatabaseList() with
|
|
{
|
|
TempdbFiles = [new DatabaseFileInfo { LogicalName = "tempdev", Type = "ROWS", PhysicalName = "t", SizeMb = 8, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 }],
|
|
};
|
|
|
|
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 16))).Findings);
|
|
|
|
Assert.Contains("Brug 8", finding.Recommendation, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tempdb_files_of_different_size()
|
|
{
|
|
static DatabaseFileInfo File(string name, long size) => new() { LogicalName = name, Type = "ROWS", PhysicalName = name, SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 };
|
|
var snapshot = DatabaseList() with { TempdbFiles = [File("a", 1024), File("b", 1024), File("c", 1024), File("d", 4096)] };
|
|
|
|
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 4))).Findings);
|
|
|
|
Assert.Equal(Severity.Low, finding.Severity);
|
|
}
|
|
}
|