Browse Source

fix: health check improvements

pull/3006/head
Jason Dove 1 week ago
parent
commit
9167615115
No known key found for this signature in database
  1. 4
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Health/Queries/GetAllHealthCheckResults.cs
  3. 16
      ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs
  4. 10
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs
  5. 1
      ErsatzTV.Core/Health/IHealthCheckService.cs
  6. 159
      ErsatzTV.Infrastructure/Health/HealthCheckService.cs
  7. 31
      ErsatzTV/Pages/Index.razor
  8. 2
      ErsatzTV/Startup.cs

4
CHANGELOG.md

@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix health checks causing a flood of (harmless) logged errors when quickly navigating away from home page
- Health check results will now be cached for 5 minutes by default; a refresh button has been added to immediately re-run all checks
## [26.9.0] - 2026-09-06
### Fixed
- Fix text subtitle playback (regression from `v26.8.1`)

2
ErsatzTV.Application/Health/Queries/GetAllHealthCheckResults.cs

@ -2,4 +2,4 @@ @@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResults : IRequest<List<HealthCheckResult>>;
public record GetAllHealthCheckResults(bool Refresh) : IRequest<List<HealthCheckResult>>;

16
ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs

@ -2,25 +2,23 @@ @@ -2,25 +2,23 @@
namespace ErsatzTV.Application.Health;
public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
public class GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService)
: IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
{
private readonly IHealthCheckService _healthCheckService;
public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) =>
_healthCheckService = healthCheckService;
public async Task<List<HealthCheckResult>> Handle(
GetAllHealthCheckResults request,
CancellationToken cancellationToken)
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
List<HealthCheckResult> results = request.Refresh
? await healthCheckService.PerformHealthChecks(cancellationToken)
: await healthCheckService.GetCachedHealthChecks(cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
return new List<HealthCheckResult>();
return [];
}
}
}

10
ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedArtworkHandler.cs

@ -43,24 +43,24 @@ public class DeleteOrphanedArtworkHandler( @@ -43,24 +43,24 @@ public class DeleteOrphanedArtworkHandler(
int deletedActors = await artworkRepository.DeleteOrphanedActors(request.MaxToDelete, cancellationToken);
if (deletedActors > 0)
{
logger.LogInformation("Deleted {Count} orphaned actors", deletedActors);
logger.LogDebug("Deleted {Count} orphaned actors", deletedActors);
}
else
{
logger.LogInformation("No orphaned actors to delete");
logger.LogDebug("No orphaned actors to delete");
}
int deletedArtwork = await artworkRepository.DeleteOrphanedArtwork(request.MaxToDelete, cancellationToken);
if (deletedArtwork > 0)
{
logger.LogInformation("Deleted {Count} orphaned artwork", deletedArtwork);
logger.LogDebug("Deleted {Count} orphaned artwork", deletedArtwork);
}
else
{
logger.LogInformation("No orphaned artwork to delete");
logger.LogDebug("No orphaned artwork to delete");
}
logger.LogInformation("Done cleaning!");
logger.LogDebug("Done cleaning!");
}
private async Task CleanUpFileSystem(CancellationToken cancellationToken)

1
ErsatzTV.Core/Health/IHealthCheckService.cs

@ -3,5 +3,6 @@ @@ -3,5 +3,6 @@
public interface IHealthCheckService
{
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
Task<List<HealthCheckResult>> GetCachedHealthChecks(CancellationToken cancellationToken);
HealthCheckSummary GetHealthCheckSummary();
}

159
ErsatzTV.Infrastructure/Health/HealthCheckService.cs

@ -2,63 +2,91 @@ @@ -2,63 +2,91 @@
using ErsatzTV.Core.Health.Checks;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Health;
public class HealthCheckService : IHealthCheckService
public class HealthCheckService(
IServiceScopeFactory serviceScopeFactory,
IMemoryCache memoryCache,
ILogger<HealthCheckService> logger) : IHealthCheckService
{
private const string CacheKey = "healthcheck.summary";
private readonly List<IHealthCheck> _checks; // ReSharper disable SuggestBaseTypeForParameterInConstructor
private readonly IMemoryCache _memoryCache;
private readonly IMediator _mediator;
private readonly ILogger<HealthCheckService> _logger;
public HealthCheckService(
IMacOsConfigFolderHealthCheck macOsConfigFolderHealthCheck,
IFFmpegVersionHealthCheck ffmpegVersionHealthCheck,
IFFmpegCapabilitiesHealthCheck ffmpegCapabilitiesHealthCheck,
IFFmpegReportsHealthCheck ffmpegReportsHealthCheck,
IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck,
IMovieMetadataHealthCheck movieMetadataHealthCheck,
IEpisodeMetadataHealthCheck episodeMetadataHealthCheck,
IZeroDurationHealthCheck zeroDurationHealthCheck,
IFileNotFoundHealthCheck fileNotFoundHealthCheck,
IUnavailableHealthCheck unavailableHealthCheck,
IVaapiDriverHealthCheck vaapiDriverHealthCheck,
IUnifiedDockerHealthCheck unifiedDockerHealthCheck,
IDowngradeHealthCheck downgradeHealthCheck,
IEmptyScheduleHealthCheck emptyScheduleHealthCheck,
IMemoryCache memoryCache,
IMediator mediator,
ILogger<HealthCheckService> logger)
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(15);
// this order is also the display order
private static readonly Type[] CheckTypes =
[
typeof(IDowngradeHealthCheck),
typeof(IMacOsConfigFolderHealthCheck),
typeof(IUnifiedDockerHealthCheck),
typeof(IFFmpegVersionHealthCheck),
typeof(IFFmpegCapabilitiesHealthCheck),
typeof(IFFmpegReportsHealthCheck),
typeof(IHardwareAccelerationHealthCheck),
typeof(IMovieMetadataHealthCheck),
typeof(IEpisodeMetadataHealthCheck),
typeof(IZeroDurationHealthCheck),
typeof(IFileNotFoundHealthCheck),
typeof(IUnavailableHealthCheck),
typeof(IEmptyScheduleHealthCheck),
typeof(IVaapiDriverHealthCheck)
];
private readonly Lock _sync = new();
private Task<List<HealthCheckResult>> _inFlight;
private List<HealthCheckResult> _results;
private DateTimeOffset _resultsExpireAt;
public Task<List<HealthCheckResult>> GetCachedHealthChecks(CancellationToken cancellationToken) =>
Run(bypassCache: false, cancellationToken);
public Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken) =>
Run(bypassCache: true, cancellationToken);
public HealthCheckSummary GetHealthCheckSummary() =>
memoryCache.Get<HealthCheckSummary>(CacheKey) ?? new HealthCheckSummary(0, 0);
private async Task<List<HealthCheckResult>> Run(bool bypassCache, CancellationToken cancellationToken)
{
_memoryCache = memoryCache;
_mediator = mediator;
_logger = logger;
_checks =
[
downgradeHealthCheck,
macOsConfigFolderHealthCheck,
unifiedDockerHealthCheck,
ffmpegVersionHealthCheck,
ffmpegCapabilitiesHealthCheck,
ffmpegReportsHealthCheck,
hardwareAccelerationHealthCheck,
movieMetadataHealthCheck,
episodeMetadataHealthCheck,
zeroDurationHealthCheck,
fileNotFoundHealthCheck,
unavailableHealthCheck,
emptyScheduleHealthCheck,
vaapiDriverHealthCheck
];
Task<List<HealthCheckResult>> run;
lock (_sync)
{
if (!bypassCache && _results is not null && DateTimeOffset.UtcNow < _resultsExpireAt)
{
return _results;
}
// only one run at a time; two runs at once start ffmpeg twice, and can finish
// out of order and store stale results
if (_inFlight is null || _inFlight.IsCompleted)
{
_inFlight = RunChecks();
}
run = _inFlight;
}
return await run.WaitAsync(cancellationToken);
}
public async Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken)
private async Task<List<HealthCheckResult>> RunChecks()
{
List<HealthCheckResult> result = await _checks.Map(c =>
// this token is not the caller's; the checks start ffmpeg and read the whole media
// library, so a closed page must not stop a run that other callers share
using var cts = new CancellationTokenSource(RunTimeout);
CancellationToken cancellationToken = cts.Token;
using IServiceScope scope = serviceScopeFactory.CreateScope();
List<HealthCheckResult> results = await CheckTypes
.Map(t => (IHealthCheck)scope.ServiceProvider.GetRequiredService(t))
.Map(c =>
{
var failedResult = new HealthCheckResult(
c.Title,
@ -69,27 +97,46 @@ public class HealthCheckService : IHealthCheckService @@ -69,27 +97,46 @@ public class HealthCheckService : IHealthCheckService
return TryAsync(() => c.Check(cancellationToken)).IfFail(ex => LogAndReturn(ex, failedResult));
})
.SequenceParallel()
.Map(results => results.ToList());
.Map(r => r.ToList());
if (cancellationToken.IsCancellationRequested)
{
// after a timeout each unfinished check reports a failure that is not real, so do not
// cache or publish it; an exception here stops the host through RunHealthChecksService
logger.LogWarning("Health checks did not complete within {Timeout}", RunTimeout);
return results;
}
var summary = new HealthCheckSummary(
result.Count(x => x.Status is HealthCheckStatus.Warning),
result.Count(x => x.Status is HealthCheckStatus.Fail));
results.Count(x => x.Status is HealthCheckStatus.Warning),
results.Count(x => x.Status is HealthCheckStatus.Fail));
_memoryCache.Set(CacheKey, summary);
lock (_sync)
{
_results = results;
_resultsExpireAt = DateTimeOffset.UtcNow.Add(CacheDuration);
}
await _mediator.Publish(summary, cancellationToken);
memoryCache.Set(CacheKey, summary);
return result;
}
try
{
await scope.ServiceProvider.GetRequiredService<IMediator>().Publish(summary, cancellationToken);
}
catch (Exception ex)
{
// an exception from a notification handler must not stop the host
logger.LogWarning(ex, "Failed to publish health check summary");
}
public HealthCheckSummary GetHealthCheckSummary() =>
_memoryCache.Get<HealthCheckSummary>(CacheKey) ?? new HealthCheckSummary(0, 0);
return results;
}
private HealthCheckResult LogAndReturn(Exception ex, HealthCheckResult failedResult)
{
if (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to run health check {Title}", failedResult.Title);
logger.LogWarning(ex, "Failed to run health check {Title}", failedResult.Title);
}
return failedResult;

31
ErsatzTV/Pages/Index.razor

@ -50,12 +50,20 @@ @@ -50,12 +50,20 @@
}
else
{
<MudText Typo="Typo.h5" Class="mb-2">Health Checks</MudText>
<div class="d-flex flex-row align-center mb-2">
<MudText Typo="Typo.h5">Health Checks</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Size="Size.Small"
Class="ml-2"
title="Re-run health checks"
OnClick="@RefreshHealthChecks"/>
</div>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
Breakpoint="Breakpoint.None"
ServerData="@(ServerReload)">
ServerData="@(ServerReload)"
@ref="_healthCheckTable">
<HeaderContent>
<MudTh>Check</MudTh>
<MudTh>Message</MudTh>
@ -145,6 +153,10 @@ @@ -145,6 +153,10 @@
@code {
private CancellationTokenSource _cts;
private MudTable<HealthCheckResult> _healthCheckTable;
private bool _refreshHealthChecks;
private string _releaseNotes;
protected override void OnInitialized()
@ -234,9 +246,22 @@ @@ -234,9 +246,22 @@
}
}
private async Task RefreshHealthChecks()
{
_refreshHealthChecks = true;
if (_healthCheckTable != null)
{
await _healthCheckTable.ReloadServerData();
}
}
private async Task<TableData<HealthCheckResult>> ServerReload(TableState state, CancellationToken cancellationToken)
{
List<HealthCheckResult> healthCheckResults = await Mediator.Send(new GetAllHealthCheckResults(), cancellationToken);
bool refresh = _refreshHealthChecks;
_refreshHealthChecks = false;
List<HealthCheckResult> healthCheckResults = await Mediator.Send(new GetAllHealthCheckResults(refresh), cancellationToken);
return new TableData<HealthCheckResult>
{

2
ErsatzTV/Startup.cs

@ -847,6 +847,7 @@ public class Startup @@ -847,6 +847,7 @@ public class Startup
services.AddSingleton<RecyclableMemoryStreamManager>();
services.AddSingleton<SystemStartup>();
services.AddSingleton<ILanguageCodeCache, LanguageCodeCache>();
services.AddSingleton<IHealthCheckService, HealthCheckService>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
AddChannel<IJellyfinBackgroundServiceRequest>(services);
@ -869,7 +870,6 @@ public class Startup @@ -869,7 +870,6 @@ public class Startup
services.AddScoped<IUnifiedDockerHealthCheck, UnifiedDockerHealthCheck>();
services.AddScoped<IDowngradeHealthCheck, DowngradeHealthCheck>();
services.AddScoped<IEmptyScheduleHealthCheck, EmptyScheduleHealthCheck>();
services.AddScoped<IHealthCheckService, HealthCheckService>();
services.AddScoped<IChannelRepository, ChannelRepository>();
services.AddScoped<IFFmpegProfileRepository, FFmpegProfileRepository>();

Loading…
Cancel
Save