using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
///
/// Service for checking the health of download clients and arr instances
///
public class HealthCheckService : IHealthCheckService
{
private readonly ILogger _logger;
private readonly Dictionary _healthStatuses = new();
private readonly Dictionary _arrHealthStatuses = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeProvider _timeProvider;
private readonly object _lockObject = new();
///
/// Occurs when a client's health status changes
///
public event EventHandler? ClientHealthChanged;
///
/// Occurs when a client is no longer covered by the health sweep
///
public event EventHandler? ClientHealthRemoved;
///
/// Occurs when an arr instance is no longer covered by the health sweep
///
public event EventHandler? ArrInstanceHealthRemoved;
public HealthCheckService(
ILogger logger,
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider
)
{
_logger = logger;
_scopeFactory = scopeFactory;
_timeProvider = timeProvider;
}
///
public async Task CheckClientHealthAsync(Guid clientId)
{
_logger.LogDebug("Checking health for client {ClientId}", clientId);
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService();
// Get the client configuration
var downloadClientConfig = await dataContext.DownloadClients
.Where(x => x.Id == clientId)
.FirstOrDefaultAsync();
if (downloadClientConfig is null)
{
_logger.LogWarning("Client {ClientId} not found in configuration", clientId);
var notFoundStatus = new HealthStatus
{
ClientId = clientId,
IsHealthy = false,
LastChecked = _timeProvider.GetUtcNow(),
ErrorMessage = "Client not found in configuration"
};
UpdateHealthStatus(notFoundStatus);
return notFoundStatus;
}
// Get the client instance
var downloadServiceFactory = scope.ServiceProvider.GetRequiredService();
var client = downloadServiceFactory.GetDownloadService(downloadClientConfig);
// Execute the health check
var healthResult = await client.HealthCheckAsync();
// Create health status object
var status = new HealthStatus
{
ClientId = clientId,
ClientName = downloadClientConfig.Name,
ClientTypeName = downloadClientConfig.TypeName,
IsHealthy = healthResult.IsHealthy,
LastChecked = _timeProvider.GetUtcNow(),
ErrorMessage = healthResult.ErrorMessage,
ResponseTime = healthResult.ResponseTime
};
UpdateHealthStatus(status);
return status;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing health check for client {ClientId}", clientId);
var status = new HealthStatus
{
ClientId = clientId,
IsHealthy = false,
LastChecked = _timeProvider.GetUtcNow(),
ErrorMessage = $"Error: {ex.Message}"
};
UpdateHealthStatus(status);
return status;
}
}
///
public async Task> CheckAllClientsHealthAsync()
{
_logger.LogDebug("Checking health for all enabled clients");
// Captured before anything is awaited: a later check replaces the instance and survives the prune.
Dictionary knownAtStart = SnapshotClients();
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService();
// Get all enabled client configurations
var enabledClients = (await dataContext.DownloadClients
.Where(x => x.Enabled)
.ToListAsync())
.Where(x => !EnumSentinel.IsUnknown(x.TypeName) && !EnumSentinel.IsUnknown(x.Type))
.ToList();
var results = new Dictionary();
// Check health of each enabled client
foreach (var clientConfig in enabledClients)
{
var status = await CheckClientHealthAsync(clientConfig.Id);
results[clientConfig.Id] = status;
}
PruneClientHealthStatuses(knownAtStart);
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for all clients");
return new Dictionary();
}
}
///
public HealthStatus? GetClientHealth(Guid clientId)
{
lock (_lockObject)
{
return _healthStatuses.TryGetValue(clientId, out var status) ? status : null;
}
}
///
public IDictionary GetAllClientHealth()
{
lock (_lockObject)
{
return new Dictionary(_healthStatuses);
}
}
///
public async Task CheckArrInstanceHealthAsync(Guid instanceId)
{
_logger.LogDebug("Checking health for arr instance {InstanceId}", instanceId);
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService();
// Get the arr instance with its config (needed for InstanceType)
// Load config with instances first, then find in memory (SQLite doesn't support APPLY)
var config = await dataContext.ArrConfigs
.Include(x => x.Instances)
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
var arrInstance = config is null ? null : new
{
Instance = config.Instances.First(i => i.Id == instanceId),
Config = config
};
if (arrInstance is null)
{
_logger.LogWarning("Arr instance {InstanceId} not found in configuration", instanceId);
var notFoundStatus = new ArrHealthStatus
{
InstanceId = instanceId,
IsHealthy = false,
LastChecked = _timeProvider.GetUtcNow(),
ErrorMessage = "Arr instance not found in configuration"
};
UpdateArrHealthStatus(notFoundStatus);
return notFoundStatus;
}
// Get the arr client and execute health check
var healthChecker = scope.ServiceProvider.GetRequiredService();
await healthChecker.CheckAsync(arrInstance.Config.Type, arrInstance.Instance);
var status = new ArrHealthStatus
{
InstanceId = instanceId,
InstanceName = arrInstance.Instance.Name,
InstanceType = arrInstance.Config.Type,
IsHealthy = true,
LastChecked = _timeProvider.GetUtcNow()
};
UpdateArrHealthStatus(status);
return status;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing health check for arr instance {InstanceId}", instanceId);
var status = new ArrHealthStatus
{
InstanceId = instanceId,
IsHealthy = false,
LastChecked = _timeProvider.GetUtcNow(),
ErrorMessage = $"Error: {ex.Message}"
};
UpdateArrHealthStatus(status);
return status;
}
}
///
public async Task> CheckAllArrInstancesHealthAsync()
{
_logger.LogDebug("Checking health for all enabled arr instances");
// Captured before anything is awaited: a later check replaces the instance and survives the prune.
Dictionary knownAtStart = SnapshotArrInstances();
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService();
// Get all enabled arr instances across all configs
// Load configs with instances first, then flatten in memory (SQLite doesn't support APPLY)
var configs = (await dataContext.ArrConfigs
.Include(x => x.Instances)
.ToListAsync())
.Where(x => !EnumSentinel.IsUnknown(x.Type))
.ToList();
var enabledInstances = configs
.SelectMany(c => c.Instances
.Where(i => i.Enabled)
.Select(i => new { Instance = i, Config = c }))
.ToList();
var results = new Dictionary();
var healthChecker = scope.ServiceProvider.GetRequiredService();
foreach (var entry in enabledInstances)
{
try
{
await healthChecker.CheckAsync(entry.Config.Type, entry.Instance);
var status = new ArrHealthStatus
{
InstanceId = entry.Instance.Id,
InstanceName = entry.Instance.Name,
InstanceType = entry.Config.Type,
IsHealthy = true,
LastChecked = _timeProvider.GetUtcNow()
};
UpdateArrHealthStatus(status);
results[entry.Instance.Id] = status;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error performing health check for arr instance {InstanceId} ({InstanceName})",
entry.Instance.Id, entry.Instance.Name);
var status = new ArrHealthStatus
{
InstanceId = entry.Instance.Id,
InstanceName = entry.Instance.Name,
InstanceType = entry.Config.Type,
IsHealthy = false,
LastChecked = _timeProvider.GetUtcNow(),
ErrorMessage = $"Error: {ex.Message}"
};
UpdateArrHealthStatus(status);
results[entry.Instance.Id] = status;
}
}
PruneArrHealthStatuses(knownAtStart);
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for all arr instances");
return new Dictionary();
}
}
///
public ArrHealthStatus? GetArrInstanceHealth(Guid instanceId)
{
lock (_lockObject)
{
return _arrHealthStatuses.TryGetValue(instanceId, out var status) ? status : null;
}
}
///
public IDictionary GetAllArrInstanceHealth()
{
lock (_lockObject)
{
return new Dictionary(_arrHealthStatuses);
}
}
private Dictionary SnapshotClients()
{
lock (_lockObject)
{
return new Dictionary(_healthStatuses);
}
}
private Dictionary SnapshotArrInstances()
{
lock (_lockObject)
{
return new Dictionary(_arrHealthStatuses);
}
}
///
/// Drops cached clients that have been invalidated.
///
private void PruneClientHealthStatuses(Dictionary knownAtStart)
{
List removed = [];
lock (_lockObject)
{
foreach ((Guid clientId, HealthStatus snapshot) in knownAtStart)
{
if (!_healthStatuses.TryGetValue(clientId, out HealthStatus? cached)
|| !ReferenceEquals(cached, snapshot))
{
continue;
}
_healthStatuses.Remove(clientId);
removed.Add(clientId);
}
}
foreach (Guid clientId in removed)
{
_logger.LogDebug("Client {ClientId} dropped from the health cache", clientId);
ClientHealthRemoved?.Invoke(this, new ClientHealthRemovedEventArgs(clientId));
}
}
///
/// Drops cached arr instances that have been invalidated.
///
private void PruneArrHealthStatuses(Dictionary knownAtStart)
{
List removed = [];
lock (_lockObject)
{
foreach ((Guid instanceId, ArrHealthStatus snapshot) in knownAtStart)
{
if (!_arrHealthStatuses.TryGetValue(instanceId, out ArrHealthStatus? cached)
|| !ReferenceEquals(cached, snapshot))
{
continue;
}
_arrHealthStatuses.Remove(instanceId);
removed.Add(instanceId);
}
}
foreach (Guid instanceId in removed)
{
_logger.LogDebug("Arr instance {InstanceId} dropped from the health cache", instanceId);
ArrInstanceHealthRemoved?.Invoke(this, new ArrInstanceHealthRemovedEventArgs(instanceId));
}
}
private void UpdateArrHealthStatus(ArrHealthStatus newStatus)
{
ArrHealthStatus? previousStatus;
lock (_lockObject)
{
_arrHealthStatuses.TryGetValue(newStatus.InstanceId, out previousStatus);
_arrHealthStatuses[newStatus.InstanceId] = newStatus;
}
bool isStateChange = previousStatus == null ||
previousStatus.IsHealthy != newStatus.IsHealthy;
if (isStateChange)
{
_logger.LogInformation(
"Arr instance {InstanceId} ({InstanceName}) health changed: {Status}",
newStatus.InstanceId,
newStatus.InstanceName,
newStatus.IsHealthy ? "Healthy" : "Unhealthy");
}
}
private void UpdateHealthStatus(HealthStatus newStatus)
{
HealthStatus? previousStatus;
lock (_lockObject)
{
// Get previous status for comparison
_healthStatuses.TryGetValue(newStatus.ClientId, out previousStatus);
// Update status
_healthStatuses[newStatus.ClientId] = newStatus;
}
// Determine if there's a significant change
bool isStateChange = previousStatus == null ||
previousStatus.IsHealthy != newStatus.IsHealthy;
// Raise event if there's a significant change
if (isStateChange)
{
_logger.LogInformation(
"Client {ClientId} health changed: {Status}",
newStatus.ClientId,
newStatus.IsHealthy ? "Healthy" : "Unhealthy");
OnClientHealthChanged(new ClientHealthChangedEventArgs(
newStatus.ClientId,
newStatus,
previousStatus));
}
}
private void OnClientHealthChanged(ClientHealthChangedEventArgs e)
{
ClientHealthChanged?.Invoke(this, e);
}
}