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:
cesnimda
2026-07-02 21:49:58 +02:00
parent e352aaeaac
commit 999d6e05e7
6 changed files with 298 additions and 0 deletions
@@ -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.");
}
}
}
}
@@ -0,0 +1,110 @@
using Microsoft.Data.Sqlite;
namespace JobTrackerApi.Services
{
public interface IDatabaseBackupRunner
{
string BackupsRoot { get; }
bool IsSupported { get; }
/// <summary>Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported.</summary>
Task<string?> RunOnceAsync(CancellationToken ct);
DateTime? GetLatestBackupUtc();
}
public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner
{
public const string BackupFilePrefix = "jobtracker_backup_";
private readonly ILogger<SqliteDatabaseBackupRunner> _logger;
private readonly string _connectionString;
private readonly int _retainCount;
public string BackupsRoot { get; }
public bool IsSupported { get; }
public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
_connectionString = cs;
IsSupported = provider == "sqlite";
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
}
// Test-friendly constructor.
public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
_connectionString = connectionString;
IsSupported = true;
BackupsRoot = backupsRoot;
_retainCount = Math.Clamp(retainCount, 1, 365);
}
public async Task<string?> RunOnceAsync(CancellationToken ct)
{
if (!IsSupported)
{
_logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB.");
return null;
}
Directory.CreateDirectory(BackupsRoot);
var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db");
if (File.Exists(target)) File.Delete(target);
await using (var connection = new SqliteConnection(_connectionString))
{
await connection.OpenAsync(ct);
await using var command = connection.CreateCommand();
// VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL).
command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'";
await command.ExecuteNonQueryAsync(ct);
}
_logger.LogInformation("Database backup written: {File}.", target);
PruneOldBackups();
return target;
}
public DateTime? GetLatestBackupUtc()
{
if (!Directory.Exists(BackupsRoot)) return null;
var latest = ListBackups().FirstOrDefault();
return latest?.LastWriteTimeUtc;
}
private void PruneOldBackups()
{
foreach (var stale in ListBackups().Skip(_retainCount))
{
try
{
stale.Delete();
_logger.LogInformation("Pruned old database backup: {File}.", stale.Name);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name);
}
}
}
private IOrderedEnumerable<FileInfo> ListBackups()
=> new DirectoryInfo(BackupsRoot)
.EnumerateFiles($"{BackupFilePrefix}*.db")
.OrderByDescending(f => f.LastWriteTimeUtc);
}
}