sqlinsight-pro/tests/SQLInsight.Rules.Tests/AvailabilityRuleTests.cs
Søren Eggert Lundsteen Olsen d27323a1a4 Availability groups: cluster node or witness down (D)
The availability group collector also reads sys.dm_hadr_cluster_members
(empty or unavailable without a WSFC), and SQLI-AG-003 (High) reports a
node or witness that is not up, with how many members are still up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:56:43 +02:00

138 lines
5.9 KiB
C#

using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Availability;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class AvailabilityRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static RuleContext Hadr(AvailabilityGroupSnapshot snapshot)
{
var info = new InstanceInfo { ServerName = "n1", Version = SqlServerVersion.Parse("16.0.4295.3"), Edition = "Enterprise", EngineEdition = EngineEdition.Enterprise, IsHadrEnabled = true };
var context = new RuleContext(info, InstanceCapabilities.From(info), Now);
context.Data.Set(snapshot);
return context;
}
[Fact]
public void A_cluster_node_or_witness_that_is_not_up_is_high()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now, Replicas = [], Databases = [],
ClusterMembers =
[
new ClusterMember("SQL01", "CLUSTER_NODE", 1, "UP", 1),
new ClusterMember("SQL02", "CLUSTER_NODE", 0, "DOWN", 1),
new ClusterMember("Cloud Witness", "CLOUD_WITNESS", 1, "UP", 1),
],
};
var finding = Assert.Single(Run(new ClusterMemberDownRule(), Hadr(snapshot)).Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Equal("cluster member: SQL02", finding.Subject);
Assert.Equal("2 af 3", finding.Evidence.Single(e => e.Name == "Medlemmer oppe").Value);
}
private static DatabaseReplicaState Db(string replica, bool primary, DateTime? commit = null, long redoKb = 0, long redoRate = 1000, bool suspended = false, string health = "HEALTHY") => new()
{
Group = "ag1", Database = "Sales", Replica = replica, IsPrimaryReplica = primary, LastCommitTime = commit,
RedoQueueKb = redoKb, RedoRateKbPerSecond = redoRate, IsSuspended = suspended, SuspendReason = suspended ? "SUSPEND_FROM_USER" : null,
SynchronizationHealth = health, SynchronizationState = "SYNCHRONIZED", LogSendQueueKb = 0,
};
[Fact]
public void Not_measurable_without_hadr()
{
Assert.Equal(RuleOutcome.NotMeasurable, Run(new AvailabilityGroupHealthRule(), Context("16.0.4295.3")).Outcome);
}
[Fact]
public void Healthy_group_passes()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now,
Replicas = [new ReplicaState { Group = "ag1", Replica = "n1", Role = "PRIMARY", ConnectedState = "CONNECTED", SynchronizationHealth = "HEALTHY" }],
Databases = [Db("n1", true, Now.UtcDateTime), Db("n2", false, Now.UtcDateTime)],
};
Assert.Equal(RuleOutcome.Passed, Run(new AvailabilityGroupHealthRule(), Hadr(snapshot)).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new AvailabilityGroupLagRule(), Hadr(snapshot)).Outcome);
}
[Fact]
public void Disconnected_replica_and_suspended_database_are_high()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now,
Replicas = [new ReplicaState { Group = "ag1", Replica = "n2", Role = "SECONDARY", ConnectedState = "DISCONNECTED", SynchronizationHealth = "NOT_HEALTHY" }],
Databases = [Db("n2", false, suspended: true, health: "NOT_HEALTHY")],
};
var findings = Run(new AvailabilityGroupHealthRule(), Hadr(snapshot)).Findings;
Assert.Equal(2, findings.Count);
Assert.All(findings, f => Assert.Equal(Severity.High, f.Severity));
Assert.Contains(findings, f => f.FixScript == "ALTER DATABASE [Sales] SET HADR RESUME;");
}
[Fact]
public void Lag_reports_data_loss_and_redo_time()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now,
Replicas = [],
Databases = [Db("n1", true, Now.UtcDateTime), Db("n2", false, Now.UtcDateTime.AddMinutes(-5), redoKb: 2_000_000, redoRate: 1000)],
};
var finding = Assert.Single(Run(new AvailabilityGroupLagRule(), Hadr(snapshot)).Findings);
Assert.Contains("300 s datatab", finding.Title, StringComparison.Ordinal);
Assert.Contains("redo ca. 33 min", finding.Title, StringComparison.Ordinal);
}
private static AgentJobSnapshot Jobs(bool running, params AgentJob[] jobs) => new() { CapturedAt = Now, IsAgentRunning = running, Jobs = jobs };
[Fact]
public void Failed_backup_job_is_high_with_step_message()
{
var job = new AgentJob { Name = "DatabaseBackup - USER_DATABASES - FULL", IsEnabled = true, LastRunStatus = 0, LastFailedStepName = "Backup", LastFailedStepMessage = "Cannot open backup device 'X:\\bak'" };
var finding = Assert.Single(Run(new FailedJobRule(), With(Jobs(true, job))).Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Contains(finding.Evidence, e => e.Value.Contains("Cannot open backup device", StringComparison.Ordinal));
}
[Fact]
public void Disabled_or_succeeding_jobs_pass()
{
var jobs = Jobs(true,
new AgentJob { Name = "Old", IsEnabled = false, LastRunStatus = 0 },
new AgentJob { Name = "Fine", IsEnabled = true, LastRunStatus = 1 },
new AgentJob { Name = "Never run", IsEnabled = true });
Assert.Equal(RuleOutcome.Passed, Run(new FailedJobRule(), With(jobs)).Outcome);
}
[Fact]
public void Stopped_agent_is_high_when_jobs_exist_but_not_on_express()
{
var jobs = Jobs(false, new AgentJob { Name = "Backup", IsEnabled = true });
Assert.Equal(Severity.High, Assert.Single(Run(new AgentStoppedRule(), With(jobs)).Findings).Severity);
var express = Context("16.0.4295.3", edition: EngineEdition.Express);
express.Data.Set(jobs);
Assert.Equal(RuleOutcome.Passed, Run(new AgentStoppedRule(), express).Outcome);
}
}