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) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 10:48:46 +02:00
parent 66129d99fc
commit 473a7d0be3
13 changed files with 354 additions and 2 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. 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).
--- ---

View file

@ -23,3 +23,24 @@ Tidspunktet er, hvornår ændringen blev opdaget, ikke hvornår den blev lavet.
## Tjenester, der ikke kører ## 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. 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 |

View file

@ -29,6 +29,7 @@ public static class BuiltInCollectors
new LongOperationCollector(), new LongOperationCollector(),
new ErrorLogCollector(settings.LoginFailureBurst), new ErrorLogCollector(settings.LoginFailureBurst),
new TempdbCollector(settings.VersionStoreMegabytes, settings.TempdbSessionMegabytes), new TempdbCollector(settings.VersionStoreMegabytes, settings.TempdbSessionMegabytes),
new ReplicationCollector(),
new PerformanceCounterCollector(), new PerformanceCounterCollector(),
new CpuCollector(), new CpuCollector(),
new BlockingCollector(), new BlockingCollector(),

View file

@ -40,6 +40,16 @@ internal static class Db
/// <summary>A [bracketed] identifier with brackets escaped.</summary> /// <summary>A [bracketed] identifier with brackets escaped.</summary>
public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
/// <summary>Runs a statement without results (USE …).</summary>
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);
}
/// <summary> /// <summary>
/// The SQL expression for when the instance started. Cumulative DMVs reset at that moment. /// The SQL expression for when the instance started. Cumulative DMVs reset at that moment.
/// </summary> /// </summary>

View file

@ -0,0 +1,69 @@
using System.Data.Common;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// 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+.
/// </summary>
public sealed class ReplicationCollector : Collector<ReplicationSnapshot>
{
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<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<ReplicationSnapshot> 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);
}
}
}

View file

@ -0,0 +1,42 @@
namespace SQLInsight.Core.Data;
/// <summary>A distribution agent's latest run, from MSdistribution_history.</summary>
/// <param name="RunStatus">1 start, 2 succeed, 3 in progress, 4 idle, 5 retry, 6 fail.</param>
/// <param name="Comment">The agent's message; can name servers and objects.</param>
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",
};
}
/// <summary>Replication as seen from the distributor, every 15 minutes.</summary>
public sealed record ReplicationSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The instance is a distributor (has a distribution database).</summary>
public bool IsDistributor { get; init; }
public string? DistributionDatabase { get; init; }
/// <summary>Why the distribution database could not be read (typically no SELECT rights); null when it could.</summary>
public string? Unavailable { get; init; }
public IReadOnlyList<DistributionAgent> Agents { get; init; } = [];
/// <summary>Rows in MSrepl_errors the last 24 hours.</summary>
public int ErrorsLastDay { get; init; }
/// <summary>Delivery latency above this (ms) is a finding.</summary>
public long LatencyThresholdMs { get; init; } = 300_000;
}

View file

@ -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
{
/// <summary>The snapshot when the instance is a distributor that could be read; otherwise not relevant or not measurable.</summary>
public static ReplicationSnapshot Readable(RuleContext context)
{
var snapshot = context.Data.Get<ReplicationSnapshot>();
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;
}
}
/// <summary>A distribution agent that failed or keeps retrying, or replication errors in the last day.</summary>
public sealed class ReplicationAgentFailedRule : Rule
{
public override string Id => "SQLI-REPL-001";
public override string Title => "Replikering fejler";
public override IReadOnlyCollection<Capability> 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}")));
}
}
/// <summary>Changes take long to reach a subscriber.</summary>
public sealed class ReplicationLatencyRule : Rule
{
public override string Id => "SQLI-REPL-002";
public override string Title => "Replikering er forsinket";
public override IReadOnlyCollection<Capability> 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}")));
}
}

View file

@ -70,6 +70,8 @@ public static class BuiltInRules
new TempdbFullRule(), new TempdbFullRule(),
new TempdbSessionRule(), new TempdbSessionRule(),
new StoppedServiceRule(), new StoppedServiceRule(),
new ReplicationAgentFailedRule(),
new ReplicationLatencyRule(),
new MemoryGrantsPendingRule(), new MemoryGrantsPendingRule(),
new AnomalyRule(), new AnomalyRule(),
new PlanRegressionRule(), new PlanRegressionRule(),

View file

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

View file

@ -152,6 +152,34 @@ else
} }
</section> </section>
@if (_replication is { IsDistributor: true } repl)
{
<section class="card" id="replikering">
<div class="card-head"><h2>Replikering</h2><span class="muted">distributionsdatabase @repl.DistributionDatabase</span></div>
@if (repl.Unavailable is { } why)
{
<p class="muted">@why</p>
}
else if (repl.Agents.Count == 0)
{
<p class="muted">Ingen distributionsagenter.</p>
}
else
{
<table>
<thead><tr><th>Agent</th><th>Database</th><th>Status</th><th>Seneste kørsel</th><th class="num">Forsinkelse</th></tr></thead>
<tbody>
@foreach (var a in repl.Agents)
{
<tr class="@(a.Failed ? "bad" : "")"><td class="mono">@a.Name</td><td>@a.PublisherDatabase</td><td>@a.StatusText</td><td class="nowrap">@Display.Time(a.LastRunAt?.UtcDateTime)</td><td class="num">@(a.DeliveryLatencyMs is { } ms ? Display.Number(ms / 1000.0) + " s" : "–")</td></tr>
}
</tbody>
</table>
<p class="muted">Replikeringsfejl det seneste døgn: @repl.ErrorsLastDay</p>
}
</section>
}
<section class="card" id="aktivitet"> <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> <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) @if (_activity is not { } activity || activity.Transactions.Count == 0 && activity.LongRequests.Count == 0)
@ -320,6 +348,7 @@ else
private IReadOnlyList<StoredLongOperation> _operations = []; private IReadOnlyList<StoredLongOperation> _operations = [];
private ActivitySnapshot? _activity; private ActivitySnapshot? _activity;
private TempdbSnapshot? _tempdb; private TempdbSnapshot? _tempdb;
private ReplicationSnapshot? _replication;
private IReadOnlyList<StoredDeadlock> _deadlocks = []; private IReadOnlyList<StoredDeadlock> _deadlocks = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@ -355,6 +384,7 @@ else
_operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50); _operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50);
_activity = await Repository.GetSnapshotAsync<ActivitySnapshot>(id, "activity"); _activity = await Repository.GetSnapshotAsync<ActivitySnapshot>(id, "activity");
_tempdb = await Repository.GetSnapshotAsync<TempdbSnapshot>(id, "tempdb"); _tempdb = await Repository.GetSnapshotAsync<TempdbSnapshot>(id, "tempdb");
_replication = await Repository.GetSnapshotAsync<ReplicationSnapshot>(id, "replication");
_deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7)); _deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7));
} }

View file

@ -0,0 +1,38 @@
using SQLInsight.Collectors.Instance;
using SQLInsight.Core.Data;
namespace SQLInsight.IntegrationTests;
/// <summary>Replication on a distributor, as the least-privilege agent: not readable without rights, readable with SELECT on the agent tables.</summary>
[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<ReplicationSnapshot>(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<ReplicationSnapshot>(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<ReplicationSnapshot>(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;");
}
}
}

View file

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

View file

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