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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user