From 473a7d0be3fb03114fb09d68169fd91322006ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 10:48:46 +0200 Subject: [PATCH] Replication from the distributor (8f) On a distributor, a 15-minute collector reads each distribution agent's latest run (status, latency, message) and replication errors the last day from the distribution database. That needs SELECT on MSdistribution_agents, MSdistribution_history and MSrepl_errors, which the agent does not have by default (never the replicated commands); without it SQLI-REPL-001 (agent failed/retrying, High) and SQLI-REPL-002 (latency > 5 min, Medium) are "not measurable" with the exact grant, and "not relevant" elsewhere. Shown on the instance page and in GET /api/v1/instances/{name}/replication. Tested against a real distributor set up in the SQL 2022 container. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/konfiguration.md | 21 +++++ .../BuiltInCollectors.cs | 1 + src/SQLInsight.Collectors/Db.cs | 10 +++ .../Instance/ReplicationCollector.cs | 69 +++++++++++++++++ src/SQLInsight.Core/Data/Replication.cs | 42 ++++++++++ .../Availability/ReplicationRules.cs | 77 +++++++++++++++++++ src/SQLInsight.Rules/BuiltInRules.cs | 2 + src/SQLInsight.Web/Api/ApiV1Endpoints.cs | 22 ++++++ .../Components/Pages/Instance.razor | 30 ++++++++ .../ReplicationTests.cs | 38 +++++++++ .../ReplicationRuleTests.cs | 40 ++++++++++ .../RuleCatalogTests.cs | 2 +- 13 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 src/SQLInsight.Collectors/Instance/ReplicationCollector.cs create mode 100644 src/SQLInsight.Core/Data/Replication.cs create mode 100644 src/SQLInsight.Rules/Availability/ReplicationRules.cs create mode 100644 tests/SQLInsight.IntegrationTests/ReplicationTests.cs create mode 100644 tests/SQLInsight.Rules.Tests/ReplicationRuleTests.cs diff --git a/README.md b/README.md index fbefb95..42f20a1 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, 79 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, 81 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/konfiguration.md b/docs/konfiguration.md index d88a89d..62a34ca 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -23,3 +23,24 @@ Tidspunktet er, hvornår ændringen blev opdaget, ikke hvornår den blev lavet. ## Tjenester, der ikke kører Reglen **SQLI-SVC-001** (Høj) melder, når en SQL Server-tjeneste, der er sat til at starte automatisk, ikke kører: typisk SQL Server Agent (så kører hverken backup eller vedligeholdelse), Full-text Filter Daemon Launcher eller Launchpad. Tjenesterne læses fra `sys.dm_server_services` sammen med konfigurationen hvert 15. minut og kræver SQL Server 2008 R2 SP1 eller nyere; på ældre versioner er reglen ikke relevant. + +## Replikering + +Er instansen distributør for replikering (har en distributionsdatabase), læser agenten hvert 15. minut hver distributionsagents seneste kørsel (status, forsinkelse, meddelelse) og antallet af replikeringsfejl det seneste døgn, og viser dem under **Replikering** på instansens side og i `GET /api/v1/instances/{navn}/replication`. + +Det kræver læseadgang til tre tabeller i distributionsdatabasen, som agenten **ikke** har som standard (de replikerede data i `MSrepl_commands` læses ikke): + +```sql +USE distribution; +CREATE USER [DOMÆNE\svc-sqlinsight$] FOR LOGIN [DOMÆNE\svc-sqlinsight$]; +GRANT SELECT ON dbo.MSdistribution_agents TO [DOMÆNE\svc-sqlinsight$]; +GRANT SELECT ON dbo.MSdistribution_history TO [DOMÆNE\svc-sqlinsight$]; +GRANT SELECT ON dbo.MSrepl_errors TO [DOMÆNE\svc-sqlinsight$]; +``` + +Uden den er reglerne "ikke målbar" med denne vejledning. På andre instanser end distributøren er de "ikke relevant". + +| Regel | Alvor | Hvornår | +|---|---|---| +| SQLI-REPL-001 | Høj | En distributionsagent fejlede eller prøver igen | +| SQLI-REPL-002 | Middel | Ændringer er over 5 minutter om at nå abonnenten | diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs index b7005d5..aac8e6f 100644 --- a/src/SQLInsight.Collectors/BuiltInCollectors.cs +++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs @@ -29,6 +29,7 @@ public static class BuiltInCollectors new LongOperationCollector(), new ErrorLogCollector(settings.LoginFailureBurst), new TempdbCollector(settings.VersionStoreMegabytes, settings.TempdbSessionMegabytes), + new ReplicationCollector(), new PerformanceCounterCollector(), new CpuCollector(), new BlockingCollector(), diff --git a/src/SQLInsight.Collectors/Db.cs b/src/SQLInsight.Collectors/Db.cs index e558e6f..f9588ba 100644 --- a/src/SQLInsight.Collectors/Db.cs +++ b/src/SQLInsight.Collectors/Db.cs @@ -40,6 +40,16 @@ internal static class Db /// A [bracketed] identifier with brackets escaped. public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; + /// Runs a statement without results (USE …). + public static async Task ExecuteAsync(DbConnection connection, string sql, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(connection); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + command.CommandTimeout = CommandTimeoutSeconds; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + /// /// The SQL expression for when the instance started. Cumulative DMVs reset at that moment. /// diff --git a/src/SQLInsight.Collectors/Instance/ReplicationCollector.cs b/src/SQLInsight.Collectors/Instance/ReplicationCollector.cs new file mode 100644 index 0000000..539114f --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/ReplicationCollector.cs @@ -0,0 +1,69 @@ +using System.Data.Common; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Instance; + +/// +/// On a distributor: each distribution agent's latest run (status, latency, message) and replication errors the last +/// day, as SELECTs in the distribution database. Reading it needs SELECT on those three tables (not on the replicated +/// commands); without it the snapshot says why. SQL Server 2005+. +/// +public sealed class ReplicationCollector : Collector +{ + internal const string DistributionDatabaseSql = "SELECT TOP 1 name FROM sys.databases WHERE is_distributor = 1 AND state = 0"; + + internal const string AgentsSql = """ + SELECT a.name, a.publisher_db, h.runstatus, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), h.time), h.delivery_latency, + CAST(h.comments AS nvarchar(500)) + FROM dbo.MSdistribution_agents a + OUTER APPLY (SELECT TOP 1 x.runstatus, x.time, x.delivery_latency, x.comments FROM dbo.MSdistribution_history x + WHERE x.agent_id = a.id ORDER BY x.timestamp DESC) h + """; + + internal const string ErrorsSql = "SELECT COUNT(*) FROM dbo.MSrepl_errors WHERE time > DATEADD(hour, -24, GETDATE())"; + + public const string NoRightsText = + "Distributionsdatabasen kan ikke læses med agentens rettigheder. Giv agenten læseadgang til netop agenternes status (ikke de replikerede data): " + + "USE distribution; CREATE USER [agentens login]; GRANT SELECT ON dbo.MSdistribution_agents TO [agentens login]; " + + "GRANT SELECT ON dbo.MSdistribution_history TO [agentens login]; GRANT SELECT ON dbo.MSrepl_errors TO [agentens login]."; + + public override string Name => "replication"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15); + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task CollectAsync(CollectorContext context, ReplicationSnapshot? previous) + { + var ct = context.CancellationToken; + var connection = context.Connection; + var distribution = (await Db.QueryAsync(connection, DistributionDatabaseSql, r => r.String(0), ct).ConfigureAwait(false)).FirstOrDefault(); + if (distribution is null) + { + return new ReplicationSnapshot { CapturedAt = context.Now }; + } + + var original = (await Db.QueryAsync(connection, "SELECT DB_NAME()", r => r.String(0), ct).ConfigureAwait(false))[0]; + try + { + var use = "USE " + Db.Identifier(distribution) + ";\n"; + var agents = await Db.QueryAsync(connection, use + AgentsSql, r => new DistributionAgent( + r.String(0), r.NullableString(1), r.NullableInt(2), + r.NullableDateTime(3) is { } at ? new DateTimeOffset(DateTime.SpecifyKind(at, DateTimeKind.Utc)) : null, + r.NullableLong(4), r.NullableString(5)), ct).ConfigureAwait(false); + var errors = (await Db.QueryAsync(connection, use + ErrorsSql, r => r.Int(0), ct).ConfigureAwait(false))[0]; + return new ReplicationSnapshot { CapturedAt = context.Now, IsDistributor = true, DistributionDatabase = distribution, Agents = agents, ErrorsLastDay = errors }; + } + catch (DbException ex) + { + return new ReplicationSnapshot + { + CapturedAt = context.Now, IsDistributor = true, DistributionDatabase = distribution, + Unavailable = NoRightsText + " (" + ex.Message.Split('\n')[0].Trim() + ")", + }; + } + finally + { + await Db.ExecuteAsync(connection, "USE " + Db.Identifier(original), ct).ConfigureAwait(false); + } + } +} diff --git a/src/SQLInsight.Core/Data/Replication.cs b/src/SQLInsight.Core/Data/Replication.cs new file mode 100644 index 0000000..a27a1ce --- /dev/null +++ b/src/SQLInsight.Core/Data/Replication.cs @@ -0,0 +1,42 @@ +namespace SQLInsight.Core.Data; + +/// A distribution agent's latest run, from MSdistribution_history. +/// 1 start, 2 succeed, 3 in progress, 4 idle, 5 retry, 6 fail. +/// The agent's message; can name servers and objects. +public sealed record DistributionAgent(string Name, string? PublisherDatabase, int? RunStatus, DateTimeOffset? LastRunAt, long? DeliveryLatencyMs, string? Comment) +{ + public bool Failed => RunStatus is 5 or 6; + + public string StatusText => RunStatus switch + { + 1 => "startet", + 2 => "lykkedes", + 3 => "i gang", + 4 => "venter (intet at levere)", + 5 => "prøver igen", + 6 => "fejlede", + _ => "ukendt", + }; +} + +/// Replication as seen from the distributor, every 15 minutes. +public sealed record ReplicationSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// The instance is a distributor (has a distribution database). + public bool IsDistributor { get; init; } + + public string? DistributionDatabase { get; init; } + + /// Why the distribution database could not be read (typically no SELECT rights); null when it could. + public string? Unavailable { get; init; } + + public IReadOnlyList Agents { get; init; } = []; + + /// Rows in MSrepl_errors the last 24 hours. + public int ErrorsLastDay { get; init; } + + /// Delivery latency above this (ms) is a finding. + public long LatencyThresholdMs { get; init; } = 300_000; +} diff --git a/src/SQLInsight.Rules/Availability/ReplicationRules.cs b/src/SQLInsight.Rules/Availability/ReplicationRules.cs new file mode 100644 index 0000000..408a53c --- /dev/null +++ b/src/SQLInsight.Rules/Availability/ReplicationRules.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using SQLInsight.Core.Collection; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Availability; + +internal static class ReplicationData +{ + /// The snapshot when the instance is a distributor that could be read; otherwise not relevant or not measurable. + public static ReplicationSnapshot Readable(RuleContext context) + { + var snapshot = context.Data.Get(); + if (!snapshot.IsDistributor) + { + throw new DataUnavailableException("Ikke relevant: instansen er ikke distributør for replikering.") { NotApplicable = true }; + } + + return snapshot.Unavailable is { } why ? throw new DataUnavailableException(why) : snapshot; + } +} + +/// A distribution agent that failed or keeps retrying, or replication errors in the last day. +public sealed class ReplicationAgentFailedRule : Rule +{ + public override string Id => "SQLI-REPL-001"; + public override string Title => "Replikering fejler"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var snapshot = ReplicationData.Readable(context); + return Result(snapshot.Agents.Where(a => a.Failed).Select(a => CreateFinding( + Severity.High, + $"Distributionsagenten for {a.PublisherDatabase ?? a.Name} {a.StatusText}", + "En distributionsagent leverer ikke ændringer til abonnenten: data hos abonnenten bliver forældet, og distributionsdatabasen og " + + "publicistens log vokser, så længe ændringerne venter.", + "Se agentens historik i Replication Monitor og meddelelsen i målingerne. Typiske årsager: abonnenten kan ikke nås, rettigheder, " + + "eller en række, der ikke findes hos abonnenten. Start agenten igen, når årsagen er rettet.", + [ + new Evidence("Agent", a.Name), + new Evidence("Status", a.StatusText), + new Evidence("Seneste kørsel", a.LastRunAt?.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "–"), + new Evidence("Fejl det seneste døgn", snapshot.ErrorsLastDay.ToString(CultureInfo.InvariantCulture)), + new Evidence("Meddelelse", a.Comment ?? "–", Sensitive: true), + ], + subject: $"replication: {a.Name}"))); + } +} + +/// Changes take long to reach a subscriber. +public sealed class ReplicationLatencyRule : Rule +{ + public override string Id => "SQLI-REPL-002"; + public override string Title => "Replikering er forsinket"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var snapshot = ReplicationData.Readable(context); + return Result(snapshot.Agents.Where(a => !a.Failed && a.DeliveryLatencyMs >= snapshot.LatencyThresholdMs).Select(a => CreateFinding( + Severity.Medium, + $"Ændringer til {a.PublisherDatabase ?? a.Name} er {Format.Number(a.DeliveryLatencyMs!.Value / 60000.0, 1)} minutter forsinket", + "Distributionsagenten leverer, men det tager lang tid, før ændringer når abonnenten. Rapporter og systemer, der læser hos abonnenten, ser gamle data.", + "Se om abonnenten eller netværket er belastet, om der er store transaktioner på publicisten, og om distributionsdatabasen er stor (oprydningsjob).", + [ + new Evidence("Agent", a.Name), + new Evidence("Forsinkelse", $"{Format.Number(a.DeliveryLatencyMs.Value / 1000.0)} s"), + new Evidence("Seneste kørsel", a.LastRunAt?.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "–"), + ], + subject: $"replication latency: {a.Name}"))); + } +} diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index b0d4af2..c1aa991 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -70,6 +70,8 @@ public static class BuiltInRules new TempdbFullRule(), new TempdbSessionRule(), new StoppedServiceRule(), + new ReplicationAgentFailedRule(), + new ReplicationLatencyRule(), new MemoryGrantsPendingRule(), new AnomalyRule(), new PlanRegressionRule(), diff --git a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs index 2baae9a..223f542 100644 --- a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs +++ b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs @@ -48,6 +48,7 @@ public static class ApiV1Endpoints 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}/replication", ReplicationAsync).WithSummary("Replikering set fra distributøren / Replication from the distributor"); 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"); } @@ -554,6 +555,27 @@ public static class ApiV1Endpoints }); } + private static async Task ReplicationAsync(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 r = await repository.GetSnapshotAsync(instance.InstanceId, "replication", ct).ConfigureAwait(false); + return Results.Ok(new + { + Instance = instance.Name, + MeasuredAt = r?.CapturedAt, + IsDistributor = r?.IsDistributor, + r?.DistributionDatabase, + r?.Unavailable, + r?.ErrorsLastDay, + Agents = (r?.Agents ?? []).Select(a => new { a.Name, a.PublisherDatabase, a.RunStatus, a.StatusText, a.LastRunAt, a.DeliveryLatencyMs, Comment = redact.Value(a.Comment) }), + }); + } + 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 dfd82d4..8a1bb01 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -152,6 +152,34 @@ else } +@if (_replication is { IsDistributor: true } repl) +{ +
+

Replikering

distributionsdatabase @repl.DistributionDatabase
+ @if (repl.Unavailable is { } why) + { +

@why

+ } + else if (repl.Agents.Count == 0) + { +

Ingen distributionsagenter.

+ } + else + { + + + + @foreach (var a in repl.Agents) + { + + } + +
AgentDatabaseStatusSeneste kørselForsinkelse
@a.Name@a.PublisherDatabase@a.StatusText@Display.Time(a.LastRunAt?.UtcDateTime)@(a.DeliveryLatencyMs is { } ms ? Display.Number(ms / 1000.0) + " s" : "–")
+

Replikeringsfejl det seneste døgn: @repl.ErrorsLastDay

+ } +
+} +

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) @@ -320,6 +348,7 @@ else private IReadOnlyList _operations = []; private ActivitySnapshot? _activity; private TempdbSnapshot? _tempdb; + private ReplicationSnapshot? _replication; private IReadOnlyList _deadlocks = []; protected override async Task OnInitializedAsync() @@ -355,6 +384,7 @@ else _operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50); _activity = await Repository.GetSnapshotAsync(id, "activity"); _tempdb = await Repository.GetSnapshotAsync(id, "tempdb"); + _replication = await Repository.GetSnapshotAsync(id, "replication"); _deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7)); } diff --git a/tests/SQLInsight.IntegrationTests/ReplicationTests.cs b/tests/SQLInsight.IntegrationTests/ReplicationTests.cs new file mode 100644 index 0000000..36874c8 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/ReplicationTests.cs @@ -0,0 +1,38 @@ +using SQLInsight.Collectors.Instance; +using SQLInsight.Core.Data; + +namespace SQLInsight.IntegrationTests; + +/// Replication on a distributor, as the least-privilege agent: not readable without rights, readable with SELECT on the agent tables. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class ReplicationTests(SqlServerFixture sql) +{ + [Fact] + public async Task A_distributor_is_unreadable_without_rights_and_readable_with_select_on_the_agent_tables() + { + await using var session = await AgentSession.OpenAsync(sql); + var before = await session.CollectAsync(new ReplicationCollector()); + Assert.False(before.IsDistributor); + + await sql.ExecuteAsSaAsync("DECLARE @s sysname; SET @s = @@SERVERNAME; EXEC sp_adddistributor @distributor = @s, @password = N'Distr-Passw0rd!';"); + await sql.ExecuteAsSaAsync("EXEC sp_adddistributiondb @database = N'distribution';"); + try + { + var denied = await session.CollectAsync(new ReplicationCollector()); + await sql.ExecuteAsSaAsync($"USE distribution; IF USER_ID(N'{SqlServerFixture.AgentLogin}') IS NULL CREATE USER [{SqlServerFixture.AgentLogin}] FOR LOGIN [{SqlServerFixture.AgentLogin}]; GRANT SELECT ON dbo.MSdistribution_agents TO [{SqlServerFixture.AgentLogin}]; GRANT SELECT ON dbo.MSdistribution_history TO [{SqlServerFixture.AgentLogin}]; GRANT SELECT ON dbo.MSrepl_errors TO [{SqlServerFixture.AgentLogin}];"); + var readable = await session.CollectAsync(new ReplicationCollector()); + + Assert.True(denied.IsDistributor); + Assert.Contains("GRANT SELECT ON dbo.MSdistribution_agents", denied.Unavailable, StringComparison.Ordinal); + Assert.True(readable.Unavailable is null, readable.Unavailable); + Assert.Empty(readable.Agents); + Assert.Equal(0, readable.ErrorsLastDay); + } + finally + { + await sql.ExecuteAsSaAsync("EXEC sp_dropdistributiondb @database = N'distribution';"); + await sql.ExecuteAsSaAsync("EXEC sp_dropdistributor @no_checks = 1, @ignore_distributor = 1;"); + } + } +} diff --git a/tests/SQLInsight.Rules.Tests/ReplicationRuleTests.cs b/tests/SQLInsight.Rules.Tests/ReplicationRuleTests.cs new file mode 100644 index 0000000..d5233c5 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/ReplicationRuleTests.cs @@ -0,0 +1,40 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Rules.Availability; +using SQLInsight.Rules.Engine; +using static SQLInsight.Rules.Tests.TestInstances; + +namespace SQLInsight.Rules.Tests; + +public class ReplicationRuleTests +{ + private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context)); + + private static ReplicationSnapshot Distributor(params DistributionAgent[] agents) => + new() { CapturedAt = Now, IsDistributor = true, DistributionDatabase = "distribution", Agents = agents }; + + [Fact] + public void Not_a_distributor_is_not_relevant_and_without_rights_not_measurable() + { + Assert.Equal(RuleOutcome.NotApplicable, Run(new ReplicationAgentFailedRule(), With(new ReplicationSnapshot { CapturedAt = Now })).Outcome); + var denied = Run(new ReplicationLatencyRule(), With(Distributor() with { Unavailable = "Giv agenten SELECT på MSdistribution_agents" })); + Assert.Equal(RuleOutcome.NotMeasurable, denied.Outcome); + Assert.Contains("MSdistribution_agents", denied.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Failed_agents_are_high_and_slow_ones_medium() + { + var failed = new DistributionAgent("SQL01-AX-Pub-SQL02-1", "AX", 6, Now, null, "The process could not connect to Subscriber 'SQL02'."); + var slow = new DistributionAgent("SQL01-AX-Pub-SQL03-2", "AX", 3, Now, 600_000, null); + var fine = new DistributionAgent("SQL01-AX-Pub-SQL04-3", "AX", 4, Now, 2_000, null); + + var errors = Run(new ReplicationAgentFailedRule(), With(Distributor(failed, slow, fine))); + var latency = Run(new ReplicationLatencyRule(), With(Distributor(failed, slow, fine))); + + var finding = Assert.Single(errors.Findings); + Assert.Equal(Severity.High, finding.Severity); + Assert.True(finding.Evidence.Single(e => e.Name == "Meddelelse").Sensitive); + Assert.Contains("SQL03", Assert.Single(latency.Findings).Evidence.Single(e => e.Name == "Agent").Value, StringComparison.Ordinal); + } +} diff --git a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs index a2b23df..f696747 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(79, BuiltInRules.Create().Count); + Assert.Equal(81, BuiltInRules.Create().Count); } }