feat: automated daily SQLite database backups with retention
New DatabaseBackupHostedService + SqliteDatabaseBackupRunner: - daily VACUUM INTO snapshot to <Data:Root>/backups (safe with WAL) - catch-up backup at startup when none exists from the last 24h - retention pruning (Backups:RetainCount, default 14) - warns and stays idle on MySQL/MariaDB where external backups apply Production previously had no automated database backup on Linux (the /api/backup endpoint is Windows-DPAPI-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed class DatabaseBackupHostedService : BackgroundService
|
||||
{
|
||||
private readonly IDatabaseBackupRunner _runner;
|
||||
private readonly ILogger<DatabaseBackupHostedService> _logger;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public DatabaseBackupHostedService(
|
||||
IDatabaseBackupRunner runner,
|
||||
ILogger<DatabaseBackupHostedService> logger,
|
||||
IConfiguration cfg,
|
||||
IStartupReadiness startupReadiness)
|
||||
{
|
||||
_runner = runner;
|
||||
_logger = logger;
|
||||
_cfg = cfg;
|
||||
_startupReadiness = startupReadiness;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
|
||||
if (!_cfg.GetValue("Backups:Enabled", true))
|
||||
{
|
||||
_logger.LogInformation("Automated database backups disabled (Backups:Enabled=false).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_runner.IsSupported)
|
||||
{
|
||||
_logger.LogWarning("Automated database backups are unavailable for the configured provider. Configure external backups for MySQL/MariaDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
var hour = _cfg.GetValue("Backups:HourLocal", 3);
|
||||
if (hour < 0 || hour > 23) hour = 3;
|
||||
|
||||
// Catch-up: guarantee at least one recent backup exists even if the
|
||||
// process never stays up long enough to reach the scheduled hour.
|
||||
var latest = _runner.GetLatestBackupUtc();
|
||||
if (latest is null || latest < DateTime.UtcNow.AddHours(-24))
|
||||
{
|
||||
await TryBackupAsync(stoppingToken);
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0);
|
||||
if (next <= now) next = next.AddDays(1);
|
||||
|
||||
_logger.LogInformation("Next database backup scheduled at {Next}.", next);
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await TryBackupAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryBackupAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _runner.RunOnceAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Database backup failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user