Review 1.5: synthetic checks: "Kør nu" joins the running check, errors are stored once, paused checks are left alone

- "Kør nu" (ServerRequestHandler) goes through SyntheticCheckService.RunNowAsync: it joins a
  run of the check that is going on, or runs it under the same MaxParallelChecks throttle
  and registers it, so it never overlaps a scheduled run. A paused check is not run: the
  answer is "Tjekket er sat på pause." and nothing is stored, evaluated or opened.
- Server requests (test, run now) may take 3 min (the check's max timeout 120 s + the
  30 s service lookup + 30 s margin) instead of AgentRequestService's 2 min, so the result
  is always stored; a server test gets 2.5 min. Stale Running requests are cleaned after
  the longest timeout + 1 min.
- An exception a check run did not foresee becomes a stored failed result ("Tjekket kunne
  ikke køres: …"), so LastRunAt advances and the check waits its interval; it is logged
  once per change. A run that cannot even be stored is not retried before its interval
  (the agent remembers when it started it).
- The service lookup of a failing check has its own 30 s deadline; its error detail
  (naming the agent's account) is Sensitive.
- The rules get max(10, FailuresBeforeFinding, SlowRunsBeforeFinding) + 1 recent results
  (at most 100), so "Fejlet i træk" and FailuresBeforeFinding > 10 work.
- The interval is never under SyntheticCheckLimits.MinIntervalSeconds agent-side; state
  for removed checks is pruned; shutdown waits for running checks before the throttle is
  disposed; a TaskCanceledException that is not shutdown no longer ends the service.

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 15:58:06 +02:00
parent 6d8a5d9ad7
commit b010f37dcf
4 changed files with 252 additions and 54 deletions

View file

@ -61,7 +61,7 @@ public sealed partial class AgentRequestService(
{ {
nextHousekeeping = now.AddMinutes(10); nextHousekeeping = now.AddMinutes(10);
await repository.PublishAgentKeyAsync(key.Describe(now), stoppingToken).ConfigureAwait(false); await repository.PublishAgentKeyAsync(key.Describe(now), stoppingToken).ConfigureAwait(false);
await repository.CleanUpAgentRequestsAsync(now, RequestTimeout + TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(30), TimeSpan.FromDays(30), stoppingToken) await repository.CleanUpAgentRequestsAsync(now, StaleAfter, TimeSpan.FromMinutes(30), TimeSpan.FromDays(30), stoppingToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@ -86,8 +86,9 @@ public sealed partial class AgentRequestService(
public async Task HandleAsync(AgentRequestRow request, CancellationToken cancellationToken) public async Task HandleAsync(AgentRequestRow request, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
var limit = TimeoutOf(request.Kind);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(RequestTimeout); timeout.CancelAfter(limit);
string? result = null; string? result = null;
string? error = null; string? error = null;
try try
@ -104,7 +105,7 @@ public sealed partial class AgentRequestService(
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
error = $"Agenten fik ikke svar inden for {RequestTimeout.TotalMinutes:0} minutter."; error = $"Agenten fik ikke svar inden for {limit.TotalMinutes:0} minutter.";
} }
// Anything else (a lock timeout, a repository error while storing a discovery …) is the request's result too, so it // Anything else (a lock timeout, a repository error while storing a discovery …) is the request's result too, so it
// is never left as Running for the web UI to wait on. // is never left as Running for the web UI to wait on.
@ -117,6 +118,15 @@ public sealed partial class AgentRequestService(
await repository.CompleteAgentRequestAsync(request.RequestId, error is null, result, error, timeProvider.GetUtcNow(), CancellationToken.None).ConfigureAwait(false); await repository.CompleteAgentRequestAsync(request.RequestId, error is null, result, error, timeProvider.GetUtcNow(), CancellationToken.None).ConfigureAwait(false);
} }
/// <summary>How long a request may take: a synthetic check run now may take its own timeout plus the lookup of its service.</summary>
public TimeSpan TimeoutOf(string kind) =>
serverRequests is not null && Servers.ServerRequestHandler.Handles(kind) ? Longest(RequestTimeout, Servers.ServerRequestHandler.RequestTimeout) : RequestTimeout;
/// <summary>A request still Running after this was lost (the agent stopped): the longest request plus a minute.</summary>
private static TimeSpan StaleAfter => Longest(RequestTimeout, Servers.ServerRequestHandler.RequestTimeout) + TimeSpan.FromMinutes(1);
private static TimeSpan Longest(TimeSpan a, TimeSpan b) => a > b ? a : b;
private static int Require(AgentRequestRow request) => private static int Require(AgentRequestRow request) =>
request.ConfiguredInstanceId ?? throw new InvalidOperationException("Anmodningen mangler en instans."); request.ConfiguredInstanceId ?? throw new InvalidOperationException("Anmodningen mangler en instans.");

View file

@ -1,5 +1,6 @@
using SQLInsight.Core.Configuration; using SQLInsight.Core.Configuration;
using SQLInsight.Core.Servers; using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Repository; using SQLInsight.Repository;
using SQLInsight.Servers; using SQLInsight.Servers;
@ -11,6 +12,18 @@ namespace SQLInsight.Agent.Servers;
/// </summary> /// </summary>
public sealed class ServerRequestHandler(InsightRepository repository, IWindowsServerSource source, SyntheticCheckService checks, TimeProvider time) public sealed class ServerRequestHandler(InsightRepository repository, IWindowsServerSource source, SyntheticCheckService checks, TimeProvider time)
{ {
/// <summary>Margin for reading the request and storing its result.</summary>
public static readonly TimeSpan Margin = TimeSpan.FromSeconds(30);
/// <summary>
/// How long such a request may take: a check's longest timeout, the lookup of its Windows service and storing the
/// result, so "Kør nu" always gets its result stored.
/// </summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(SyntheticCheckLimits.MaxTimeoutSeconds) + SyntheticCheckService.ServiceLookupTimeout + Margin;
/// <summary>How long a server test may take: what is not done by then is reported as not answered, in time to be stored.</summary>
public static readonly TimeSpan TestTimeout = RequestTimeout - Margin;
public static bool Handles(string kind) => ServerRequestKind.Handles(kind); public static bool Handles(string kind) => ServerRequestKind.Handles(kind);
/// <summary>The request's result as JSON. Throws <see cref="KeyNotFoundException"/> for a server or check that does not exist.</summary> /// <summary>The request's result as JSON. Throws <see cref="KeyNotFoundException"/> for a server or check that does not exist.</summary>
@ -33,7 +46,7 @@ public sealed class ServerRequestHandler(InsightRepository repository, IWindowsS
var server = await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false) var server = await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Server {serverId} findes ikke."); ?? throw new KeyNotFoundException($"Server {serverId} findes ikke.");
var account = OperatingSystem.IsWindows() ? CimWindowsServerSource.AgentAccount(server.Host) : null; var account = OperatingSystem.IsWindows() ? CimWindowsServerSource.AgentAccount(server.Host) : null;
var result = await WindowsServerTester.TestAsync(source, server.Host, account, time.GetUtcNow(), cancellationToken).ConfigureAwait(false); var result = await WindowsServerTester.TestAsync(source, server.Host, account, time.GetUtcNow(), TestTimeout, cancellationToken).ConfigureAwait(false);
if (result.Reachable) if (result.Reachable)
{ {
await repository.RecordServerAreasAsync(serverId, result.Areas, cancellationToken).ConfigureAwait(false); await repository.RecordServerAreasAsync(serverId, result.Areas, cancellationToken).ConfigureAwait(false);
@ -42,10 +55,11 @@ public sealed class ServerRequestHandler(InsightRepository repository, IWindowsS
return result; return result;
} }
public async Task<Core.SyntheticChecks.SyntheticCheckResult> RunAsync(int checkId, CancellationToken cancellationToken) /// <summary>"Kør nu": joins a run of the check that is going on, or runs it within the limit on parallel checks; a paused check is not run.</summary>
public async Task<SyntheticCheckResult> RunAsync(int checkId, CancellationToken cancellationToken)
{ {
var check = await repository.GetSyntheticCheckAsync(checkId, cancellationToken).ConfigureAwait(false) var check = await repository.GetSyntheticCheckAsync(checkId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Tjek {checkId} findes ikke."); ?? throw new KeyNotFoundException($"Tjek {checkId} findes ikke.");
return await checks.RunCheckAsync(check, cancellationToken).ConfigureAwait(false); return await checks.RunNowAsync(check, cancellationToken).ConfigureAwait(false);
} }
} }

View file

@ -12,7 +12,8 @@ namespace SQLInsight.Agent.Servers;
/// <summary> /// <summary>
/// Runs the synthetic checks on their schedule (and on request from SQLInsight Web), as the agent's identity: stores /// Runs the synthetic checks on their schedule (and on request from SQLInsight Web), as the agent's identity: stores
/// each result, looks up the linked Windows service when a check fails ("svarer ikke, selv om tjenesten kører"), /// each result, looks up the linked Windows service when a check fails ("svarer ikke, selv om tjenesten kører"),
/// evaluates the rules and notifies. Never changes anything on the servers it checks. /// evaluates the rules and notifies. A check runs once at a time, and at most MaxParallelChecks run together, also when
/// asked to run now. Never changes anything on the servers it checks.
/// </summary> /// </summary>
public sealed partial class SyntheticCheckService( public sealed partial class SyntheticCheckService(
IOptions<SyntheticCheckOptions> options, IOptions<SyntheticCheckOptions> options,
@ -27,9 +28,17 @@ public sealed partial class SyntheticCheckService(
IOptions<AgentOptions> agentOptions, IOptions<AgentOptions> agentOptions,
Configuration.RepositoryReadiness readiness) : BackgroundService Configuration.RepositoryReadiness readiness) : BackgroundService
{ {
private readonly Dictionary<int, Task> _running = []; /// <summary>The answer to "Kør nu" for a paused check: it is not run.</summary>
public const string PausedMessage = "Tjekket er sat på pause.";
/// <summary>How long looking up the state of a failing check's Windows service may take.</summary>
public static readonly TimeSpan ServiceLookupTimeout = TimeSpan.FromSeconds(30);
private readonly Dictionary<int, Task<SyntheticCheckResult?>> _running = [];
private readonly Dictionary<int, DateTimeOffset> _attempts = [];
private readonly Dictionary<int, string> _reported = []; private readonly Dictionary<int, string> _reported = [];
private readonly Lock _lock = new(); private readonly Lock _lock = new();
private readonly SemaphoreSlim _throttle = new(Math.Clamp(options.Value.MaxParallelChecks, 1, 32));
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
@ -40,37 +49,37 @@ public sealed partial class SyntheticCheckService(
} }
await readiness.Ready.WaitAsync(stoppingToken).ConfigureAwait(false); await readiness.Ready.WaitAsync(stoppingToken).ConfigureAwait(false);
using var throttle = new SemaphoreSlim(Math.Max(1, settings.MaxParallelChecks));
using var timer = new PeriodicTimer(agentOptions.Value.Tick, time); using var timer = new PeriodicTimer(agentOptions.Value.Tick, time);
do try
{ {
try do
{ {
var now = time.GetUtcNow(); try
foreach (var check in await repository.GetSyntheticChecksAsync(stoppingToken).ConfigureAwait(false))
{ {
if (check.Paused || (check.LastRunAt is { } last && last.AddSeconds(check.IntervalSeconds) > now.UtcDateTime)) var now = time.GetUtcNow();
var checks = await repository.GetSyntheticChecksAsync(stoppingToken).ConfigureAwait(false);
Prune(checks);
foreach (var check in checks.Where(c => IsDue(c, now)))
{ {
continue; lock (_lock)
}
lock (_lock)
{
if (_running.TryGetValue(check.CheckId, out var task) && !task.IsCompleted)
{ {
continue; _attempts[check.CheckId] = now;
} }
_running[check.CheckId] = RunThrottledAsync(check, throttle, stoppingToken); _ = Start(check, stoppingToken);
} }
} }
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
LogRoundFailed(logger, ex);
}
} }
catch (Exception ex) when (ex is not OperationCanceledException) while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
{ }
LogRoundFailed(logger, ex); catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
} {
// Stopping: the running checks end below.
} }
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
Task[] remaining; Task[] remaining;
lock (_lock) lock (_lock)
@ -78,27 +87,118 @@ public sealed partial class SyntheticCheckService(
remaining = [.. _running.Values]; remaining = [.. _running.Values];
} }
await Task.WhenAll(remaining).ConfigureAwait(false); await ServerMonitoringService.WaitQuietlyAsync(remaining).ConfigureAwait(false);
} }
private async Task RunThrottledAsync(SyntheticCheckRow check, SemaphoreSlim throttle, CancellationToken cancellationToken) public override void Dispose()
{ {
await throttle.WaitAsync(cancellationToken).ConfigureAwait(false); base.Dispose();
_throttle.Dispose();
}
/// <summary>
/// Due when its interval (never under a minute) has passed since the last stored result and since the agent last
/// started it: a run that could not be stored is not repeated every few seconds.
/// </summary>
private bool IsDue(SyntheticCheckRow check, DateTimeOffset now)
{
if (check.Paused)
{
return false;
}
var interval = SyntheticCheckOptions.IntervalOf(check.IntervalSeconds);
if (check.LastRunAt is { } last && new DateTimeOffset(DateTime.SpecifyKind(last, DateTimeKind.Utc)) + interval > now)
{
return false;
}
lock (_lock)
{
return !(_attempts.TryGetValue(check.CheckId, out var attempted) && attempted + interval > now)
&& !(_running.TryGetValue(check.CheckId, out var running) && !running.IsCompleted);
}
}
/// <summary>Forgets checks that were removed.</summary>
private void Prune(IReadOnlyList<SyntheticCheckRow> checks)
{
var ids = checks.Select(c => c.CheckId).ToHashSet();
lock (_lock)
{
foreach (var id in _running.Where(r => !ids.Contains(r.Key) && r.Value.IsCompleted).Select(r => r.Key).ToList())
{
_running.Remove(id);
}
foreach (var id in _attempts.Keys.Where(id => !ids.Contains(id)).ToList())
{
_attempts.Remove(id);
}
foreach (var id in _reported.Keys.Where(id => !ids.Contains(id)).ToList())
{
_reported.Remove(id);
}
}
}
/// <summary>The check's run that is going on, or a new one under the limit on parallel checks.</summary>
private Task<SyntheticCheckResult?> Start(SyntheticCheckRow check, CancellationToken cancellationToken)
{
lock (_lock)
{
if (_running.TryGetValue(check.CheckId, out var running) && !running.IsCompleted)
{
return running;
}
var task = RunThrottledAsync(check, cancellationToken);
_running[check.CheckId] = task;
return task;
}
}
/// <summary>
/// "Kør nu" from SQLInsight Web: joins the check's run if one is going on, or runs it under the same limit as the
/// scheduled runs. A paused check is not run (and nothing is stored or evaluated).
/// </summary>
public async Task<SyntheticCheckResult> RunNowAsync(SyntheticCheckRow check, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(check);
if (check.Paused)
{
return new SyntheticCheckResult { At = time.GetUtcNow(), Success = false, Message = PausedMessage };
}
var result = await Start(check, cancellationToken).WaitAsync(cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Tjekket kunne ikke køres eller resultatet ikke gemmes. Se agentens log.");
}
/// <summary>One run; null when it could not be stored (logged once per change).</summary>
private async Task<SyntheticCheckResult?> RunThrottledAsync(SyntheticCheckRow check, CancellationToken cancellationToken)
{
await _throttle.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
await RunCheckAsync(check, cancellationToken).ConfigureAwait(false); return await RunCheckAsync(check, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{ {
LogCheckFailed(logger, ex, check.Name); if (IsNews(check.CheckId, "fejl: " + ex.Message, remember: true))
{
LogCheckFailed(logger, ex, check.Name);
}
return null;
} }
finally finally
{ {
throttle.Release(); _throttle.Release();
} }
} }
/// <summary>Runs a check once, stores the result and its findings. Used on schedule and for "Kør nu" in SQLInsight Web.</summary> /// <summary>Runs a check once, stores the result and its findings. Used on schedule and for "Kør nu" (through <see cref="RunNowAsync"/>).</summary>
public async Task<SyntheticCheckResult> RunCheckAsync(SyntheticCheckRow check, CancellationToken cancellationToken) public async Task<SyntheticCheckResult> RunCheckAsync(SyntheticCheckRow check, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(check); ArgumentNullException.ThrowIfNull(check);
@ -115,7 +215,23 @@ public sealed partial class SyntheticCheckService(
} }
var timeout = TimeSpan.FromSeconds(Math.Clamp(check.TimeoutSeconds, 1, SyntheticCheckLimits.MaxTimeoutSeconds)); var timeout = TimeSpan.FromSeconds(Math.Clamp(check.TimeoutSeconds, 1, SyntheticCheckLimits.MaxTimeoutSeconds));
var result = await runner.RunAsync(new SyntheticCheckRun(check.Kind, settings, timeout, password), cancellationToken).ConfigureAwait(false); SyntheticCheckResult result;
try
{
result = await runner.RunAsync(new SyntheticCheckRun(check.Kind, settings, timeout, password), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
// Not a failure the runner foresaw: still the check's result, so it is stored, waits its interval, and is logged once.
var message = "Tjekket kunne ikke køres: " + ex.Message;
if (IsNews(check.CheckId, message, remember: false))
{
LogCheckFailed(logger, ex, check.Name);
}
result = new SyntheticCheckResult { At = now, Success = false, DurationMs = (int)Math.Min(int.MaxValue, (time.GetUtcNow() - now).TotalMilliseconds), Message = message };
}
if (!result.Success && check.ServerId is { } serverId && settings.ServiceName is { Length: > 0 } service) if (!result.Success && check.ServerId is { } serverId && settings.ServiceName is { Length: > 0 } service)
{ {
result = await WithServiceStateAsync(result, serverId, service, cancellationToken).ConfigureAwait(false); result = await WithServiceStateAsync(result, serverId, service, cancellationToken).ConfigureAwait(false);
@ -152,7 +268,10 @@ public sealed partial class SyntheticCheckService(
return (password, error); return (password, error);
} }
/// <summary>The state of the Windows service behind a failing check: the difference between "nede" and "kører, men svarer ikke".</summary> /// <summary>
/// The state of the Windows service behind a failing check: the difference between "nede" and "kører, men svarer
/// ikke". Takes at most <see cref="ServiceLookupTimeout"/>.
/// </summary>
private async Task<SyntheticCheckResult> WithServiceStateAsync(SyntheticCheckResult result, int serverId, string service, CancellationToken cancellationToken) private async Task<SyntheticCheckResult> WithServiceStateAsync(SyntheticCheckResult result, int serverId, string service, CancellationToken cancellationToken)
{ {
var server = await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false); var server = await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false);
@ -161,10 +280,12 @@ public sealed partial class SyntheticCheckService(
return result; return result;
} }
using var lookup = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lookup.CancelAfter(ServiceLookupTimeout);
try try
{ {
using var session = await servers.ConnectAsync(server.Host, cancellationToken).ConfigureAwait(false); using var session = await servers.ConnectAsync(server.Host, lookup.Token).ConfigureAwait(false);
var state = await session.GetServiceAsync(service, cancellationToken).ConfigureAwait(false); var state = await session.GetServiceAsync(service, lookup.Token).ConfigureAwait(false);
return result with return result with
{ {
ServiceState = state?.State ?? "NotFound", ServiceState = state?.State ?? "NotFound",
@ -173,24 +294,34 @@ public sealed partial class SyntheticCheckService(
} }
catch (ServerAccessException ex) catch (ServerAccessException ex)
{ {
return result with { Details = [.. result.Details, new SyntheticCheckDetail("Tjeneste", $"{service} på {server.Name} kunne ikke aflæses: {ex.Message}")] }; // The reason names the agent's account: kept on-prem.
return result with { Details = [.. result.Details, new SyntheticCheckDetail("Tjeneste", $"{service} på {server.Name} kunne ikke aflæses: {ex.Message}", Sensitive: true)] };
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return result with
{
Details = [.. result.Details, new SyntheticCheckDetail("Tjeneste",
$"{service} på {server.Name} kunne ikke aflæses: serveren svarede ikke inden for {ServiceLookupTimeout.TotalSeconds:0} sekunder.")],
};
} }
} }
private async Task<SyntheticCheckResult> StoreAsync(SyntheticCheckRow check, SyntheticCheckResult result, CancellationToken cancellationToken) private async Task<SyntheticCheckResult> StoreAsync(SyntheticCheckRow check, SyntheticCheckResult result, CancellationToken cancellationToken)
{ {
await repository.RecordSyntheticCheckResultAsync(check.CheckId, result, cancellationToken).ConfigureAwait(false); await repository.RecordSyntheticCheckResultAsync(check.CheckId, result, cancellationToken).ConfigureAwait(false);
var recent = (await repository.GetSyntheticCheckResultsAsync(check.CheckId, 10, cancellationToken: cancellationToken).ConfigureAwait(false)) var settings = options.Value;
var recent = (await repository.GetSyntheticCheckResultsAsync(check.CheckId, settings.RecentResults, cancellationToken: cancellationToken).ConfigureAwait(false))
.Select(r => r.ToResult()).ToList(); .Select(r => r.ToResult()).ToList();
var settings = check.Settings; var checkSettings = check.Settings;
var identity = new SyntheticCheckIdentity(check.CheckId, check.Name, check.Kind, settings.Target(check.Kind)) var identity = new SyntheticCheckIdentity(check.CheckId, check.Name, check.Kind, checkSettings.Target(check.Kind))
{ {
ServerName = check.ServerName, ServerName = check.ServerName,
ServiceName = settings.ServiceName, ServiceName = checkSettings.ServiceName,
WarnMilliseconds = settings.WarnMilliseconds, WarnMilliseconds = checkSettings.WarnMilliseconds,
Https = settings.Url?.Scheme == Uri.UriSchemeHttps, Https = checkSettings.Url?.Scheme == Uri.UriSchemeHttps,
}; };
var results = ScopedRuleEngine.Evaluate(SyntheticCheckRules.Create(), new SyntheticCheckRuleContext(identity, result.At, recent, options.Value)); var results = ScopedRuleEngine.Evaluate(SyntheticCheckRules.Create(), new SyntheticCheckRuleContext(identity, result.At, recent, settings));
var changes = await repository.SaveScopedRuleResultsAsync(FindingScope.Check, check.CheckId, results, result.At, cancellationToken).ConfigureAwait(false); var changes = await repository.SaveScopedRuleResultsAsync(FindingScope.Check, check.CheckId, results, result.At, cancellationToken).ConfigureAwait(false);
var events = ServerNotifications.For(check.Name, changes, notificationOptions.Value.WebUrl, result.At).ToList(); var events = ServerNotifications.For(check.Name, changes, notificationOptions.Value.WebUrl, result.At).ToList();
if (events.Count > 0) if (events.Count > 0)
@ -198,19 +329,33 @@ public sealed partial class SyntheticCheckService(
await notifications.DispatchAsync(events, cancellationToken).ConfigureAwait(false); await notifications.DispatchAsync(events, cancellationToken).ConfigureAwait(false);
} }
lock (_lock) if (IsNews(check.CheckId, result.Success ? "ok" : result.Message, remember: true))
{ {
var state = result.Success ? "ok" : result.Message; LogResult(logger, check.Name, result.Success, result.Message);
if (!_reported.TryGetValue(check.CheckId, out var previous) || previous != state)
{
_reported[check.CheckId] = state;
LogResult(logger, check.Name, result.Success, result.Message);
}
} }
return result; return result;
} }
/// <summary>Whether a check's state differs from the last one logged (the same failure every run is not news).</summary>
private bool IsNews(int checkId, string state, bool remember)
{
lock (_lock)
{
if (_reported.TryGetValue(checkId, out var previous) && previous == state)
{
return false;
}
if (remember)
{
_reported[checkId] = state;
}
return true;
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "Synthetic check {Check}: success {Success}: {Message}")] [LoggerMessage(Level = LogLevel.Information, Message = "Synthetic check {Check}: success {Success}: {Message}")]
private static partial void LogResult(ILogger logger, string check, bool success, string message); private static partial void LogResult(ILogger logger, string check, bool success, string message);

View file

@ -236,6 +236,13 @@ public sealed class ServerAgentTests(SqlServerFixture sql) : IAsyncLifetime
public BusinessConnectorInstallation? Find() => null; public BusinessConnectorInstallation? Find() => null;
} }
/// <summary>Fails in a way the check runner does not foresee.</summary>
private sealed class BrokenSql : ISqlConnectionFactory
{
public Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Uventet fejl i driveren.");
}
[Fact] [Fact]
public async Task A_server_that_hangs_is_cut_off_at_the_cycle_deadline_and_backed_off() public async Task A_server_that_hangs_is_cut_off_at_the_cycle_deadline_and_backed_off()
{ {
@ -262,6 +269,28 @@ public sealed class ServerAgentTests(SqlServerFixture sql) : IAsyncLifetime
Assert.Equal(DateTimeOffset.MinValue, monitor.RetryAt); Assert.Equal(DateTimeOffset.MinValue, monitor.RetryAt);
} }
[Fact]
public async Task Run_now_leaves_a_paused_check_alone_and_an_unexpected_error_is_a_stored_failure()
{
var paused = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("Pause", SyntheticCheckKind.Tcp, null,
new SyntheticCheckSettings { Host = "127.0.0.1", Port = ClosedPort() }, null, null, 300, 5, Paused: true), "anna", DateTimeOffset.UtcNow);
var sql = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("SQL", SyntheticCheckKind.Sql, null, new SyntheticCheckSettings { Server = "sql01" },
null, null, 300, 5), "anna", DateTimeOffset.UtcNow);
var service = Checks(new FakeServer(), sql: new BrokenSql());
var declined = await service.RunNowAsync((await _repository.GetSyntheticCheckAsync(paused))!, CancellationToken.None);
var failed = await service.RunNowAsync((await _repository.GetSyntheticCheckAsync(sql))!, CancellationToken.None);
Assert.False(declined.Success);
Assert.Equal(SyntheticCheckService.PausedMessage, declined.Message);
Assert.Empty(await _repository.GetSyntheticCheckResultsAsync(paused));
Assert.Null((await _repository.GetSyntheticCheckAsync(paused))!.LastRunAt);
Assert.Empty(await _repository.GetServerRuleStatesAsync(FindingScope.Check, paused));
Assert.False(failed.Success);
Assert.StartsWith("Tjekket kunne ikke køres: Uventet fejl i driveren.", failed.Message, StringComparison.Ordinal);
Assert.NotNull((await _repository.GetSyntheticCheckAsync(sql))!.LastRunAt); // stored: waits its interval instead of running every 15 seconds
}
private static int ClosedPort() private static int ClosedPort()
{ {
using var probe = new TcpListener(IPAddress.Loopback, 0); using var probe = new TcpListener(IPAddress.Loopback, 0);