feat: Add health check monitoring for ProfileWorker
ProfileWorker changes: - Implement IHealthCheck interface - Replace _workCount with DateTime-based tracking - Track _lastSuccessfulRun, _consecutiveFailures, _lastException - Graceful shutdown handling (OperationCanceledException) - Health states: Healthy, Degraded (1-2 failures), Unhealthy (3x interval) DependencyInjection changes: - Register ProfileWorker as singleton (for health check access) - Use factory pattern for IHostedService registration Program.cs changes: - Add health check service with ProfileWorker - Map /health endpoint (full JSON response with all checks) - Map /health/ready endpoint (filtered by 'ready' tag) - Custom JSON response writer with detailed metrics
This commit is contained in:
@@ -13,9 +13,13 @@ public static class DependencyInjection
|
|||||||
// Validate options at startup
|
// Validate options at startup
|
||||||
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
|
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
|
||||||
|
|
||||||
services.AddHostedService<ProfileWorker>();
|
// Register ProfileWorker as both HostedService and singleton (for health check access)
|
||||||
|
services.AddSingleton<ProfileWorker>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<ProfileWorker>());
|
||||||
|
|
||||||
services.AddSingleton<ProfileCache>();
|
services.AddSingleton<ProfileCache>();
|
||||||
services.AddScoped<ProfileWork>();
|
services.AddScoped<ProfileWork>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace ECMJobRunner.WebCron.ProfileWorker;
|
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||||
@@ -5,35 +6,114 @@ namespace ECMJobRunner.WebCron.ProfileWorker;
|
|||||||
public class ProfileWorker(
|
public class ProfileWorker(
|
||||||
ILogger<ProfileWorker> Logger,
|
ILogger<ProfileWorker> Logger,
|
||||||
IServiceScopeFactory ScopeFactory,
|
IServiceScopeFactory ScopeFactory,
|
||||||
IOptions<ProfileWorkerOptions> Options) : BackgroundService
|
IOptions<ProfileWorkerOptions> Options) : BackgroundService, IHealthCheck
|
||||||
{
|
{
|
||||||
private readonly ProfileWorkerOptions _options = Options.Value;
|
private readonly ProfileWorkerOptions _options = Options.Value;
|
||||||
|
|
||||||
private int _workCount = 0;
|
// Health check state
|
||||||
|
private DateTime _lastSuccessfulRun = DateTime.UtcNow;
|
||||||
|
private int _consecutiveFailures = 0;
|
||||||
|
private Exception? _lastException = null;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
Logger.LogInformation("ProfileWorker started");
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_workCount++;
|
|
||||||
if (Logger.IsEnabled(LogLevel.Information))
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Worker running {workCount} at: {time}", _workCount, DateTimeOffset.Now);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
|
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
|
||||||
using var scope = ScopeFactory.CreateScope();
|
using var scope = ScopeFactory.CreateScope();
|
||||||
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
|
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
|
||||||
await work.ExecuteAsync(stoppingToken);
|
await work.ExecuteAsync(stoppingToken);
|
||||||
|
|
||||||
|
// Success - update health state
|
||||||
|
_lastSuccessfulRun = DateTime.UtcNow;
|
||||||
|
_consecutiveFailures = 0;
|
||||||
|
_lastException = null;
|
||||||
|
|
||||||
|
if (Logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ProfileWorker sync completed successfully at {time}", _lastSuccessfulRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Graceful shutdown - app is stopping
|
||||||
|
Logger.LogInformation("ProfileWorker stopping due to cancellation request");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "An unexpected error occurred in ProfileWorker. Work count: {workCount}", _workCount);
|
// Unexpected error - track for health check
|
||||||
|
_consecutiveFailures++;
|
||||||
|
_lastException = ex;
|
||||||
|
|
||||||
|
Logger.LogError(ex,
|
||||||
|
"An unexpected error occurred in ProfileWorker (consecutive failures: {failures})",
|
||||||
|
_consecutiveFailures);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(_options.IntervalMS, stoppingToken);
|
await Task.Delay(_options.IntervalMS, stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("ProfileWorker stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var timeSinceLastSuccess = DateTime.UtcNow - _lastSuccessfulRun;
|
||||||
|
var maxAllowedDelay = TimeSpan.FromMilliseconds(_options.IntervalMS * 3);
|
||||||
|
|
||||||
|
// Unhealthy: No successful run for 3x interval
|
||||||
|
if (timeSinceLastSuccess > maxAllowedDelay)
|
||||||
|
{
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["LastSuccessfulRun"] = _lastSuccessfulRun,
|
||||||
|
["TimeSinceLastSuccess"] = timeSinceLastSuccess,
|
||||||
|
["ConsecutiveFailures"] = _consecutiveFailures,
|
||||||
|
["IntervalMS"] = _options.IntervalMS
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_lastException != null)
|
||||||
|
{
|
||||||
|
data["LastException"] = _lastException.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(HealthCheckResult.Unhealthy(
|
||||||
|
$"ProfileWorker has not completed successfully for {timeSinceLastSuccess.TotalSeconds:F0} seconds ({_consecutiveFailures} consecutive failures)",
|
||||||
|
_lastException,
|
||||||
|
data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degraded: 1-2 consecutive failures but within time limit
|
||||||
|
if (_consecutiveFailures > 0)
|
||||||
|
{
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["LastSuccessfulRun"] = _lastSuccessfulRun,
|
||||||
|
["ConsecutiveFailures"] = _consecutiveFailures
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(HealthCheckResult.Degraded(
|
||||||
|
$"ProfileWorker has {_consecutiveFailures} consecutive failure(s) but still operational",
|
||||||
|
null,
|
||||||
|
data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthy
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy(
|
||||||
|
"ProfileWorker is running normally",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["LastSuccessfulRun"] = _lastSuccessfulRun
|
||||||
|
}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ builder.Services.AddHangfireServer();
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// Add health checks
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddCheck<ProfileWorker>("profile-worker", tags: new[] { "ready", "worker" });
|
||||||
|
|
||||||
// Add Serilog.UI with SQLite provider - use same path from configuration
|
// Add Serilog.UI with SQLite provider - use same path from configuration
|
||||||
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
|
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
|
||||||
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
|
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
|
||||||
@@ -139,6 +143,36 @@ app.UseSerilogUi();
|
|||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// Map health check endpoints
|
||||||
|
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = _ => true,
|
||||||
|
ResponseWriter = async (context, report) =>
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var result = System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
status = report.Status.ToString(),
|
||||||
|
timestamp = DateTime.UtcNow,
|
||||||
|
checks = report.Entries.Select(e => new
|
||||||
|
{
|
||||||
|
name = e.Key,
|
||||||
|
status = e.Value.Status.ToString(),
|
||||||
|
description = e.Value.Description,
|
||||||
|
duration = e.Value.Duration.TotalMilliseconds,
|
||||||
|
exception = e.Value.Exception?.Message,
|
||||||
|
data = e.Value.Data
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await context.Response.WriteAsync(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = check => check.Tags.Contains("ready")
|
||||||
|
});
|
||||||
|
|
||||||
// Redirect root path to Hangfire dashboard
|
// Redirect root path to Hangfire dashboard
|
||||||
app.MapGet("/", () => Results.Redirect("/hangfire"));
|
app.MapGet("/", () => Results.Redirect("/hangfire"));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user