Files
jobtrackingapp/JobTrackerApi/Services/DatabaseBackupHostedService.cs
T
cesnimda 999d6e05e7 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>
2026-07-02 21:49:58 +02:00

85 lines
2.8 KiB
C#

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.");
}
}
}
}