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,97 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class DatabaseBackupRunnerTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public DatabaseBackupRunnerTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"jt-backup-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private string CreateSourceDb(out string connectionString)
|
||||
{
|
||||
var dbPath = Path.Combine(_root, "source.db");
|
||||
connectionString = $"Data Source={dbPath}";
|
||||
using var connection = new SqliteConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE Sample (Id INTEGER PRIMARY KEY, Name TEXT); INSERT INTO Sample (Name) VALUES ('alpha'), ('beta');";
|
||||
command.ExecuteNonQuery();
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
private SqliteDatabaseBackupRunner CreateRunner(string connectionString, int retainCount = 14)
|
||||
=> new(connectionString, Path.Combine(_root, "backups"), retainCount, NullLogger<SqliteDatabaseBackupRunner>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_creates_a_restorable_backup_file()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
var runner = CreateRunner(connectionString);
|
||||
|
||||
var backupPath = await runner.RunOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(backupPath);
|
||||
Assert.True(File.Exists(backupPath));
|
||||
|
||||
await using var verify = new SqliteConnection($"Data Source={backupPath}");
|
||||
await verify.OpenAsync();
|
||||
await using var count = verify.CreateCommand();
|
||||
count.CommandText = "SELECT COUNT(*) FROM Sample";
|
||||
Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_prunes_backups_beyond_retention()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
var runner = CreateRunner(connectionString, retainCount: 2);
|
||||
var backupsRoot = runner.BackupsRoot;
|
||||
Directory.CreateDirectory(backupsRoot);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var stale = Path.Combine(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}stale{i}.db");
|
||||
File.WriteAllText(stale, "stale");
|
||||
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-10 - i));
|
||||
}
|
||||
|
||||
await runner.RunOnceAsync(CancellationToken.None);
|
||||
|
||||
var remaining = Directory.GetFiles(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}*.db");
|
||||
Assert.Equal(2, remaining.Length);
|
||||
Assert.Contains(remaining, f => Path.GetFileName(f).Contains("stale0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latest_backup_timestamp_reflects_newest_file()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
var runner = CreateRunner(connectionString);
|
||||
|
||||
Assert.Null(runner.GetLatestBackupUtc());
|
||||
|
||||
Directory.CreateDirectory(runner.BackupsRoot);
|
||||
var file = Path.Combine(runner.BackupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}x.db");
|
||||
File.WriteAllText(file, "x");
|
||||
var stamp = DateTime.UtcNow.AddHours(-3);
|
||||
File.SetLastWriteTimeUtc(file, stamp);
|
||||
|
||||
var latest = runner.GetLatestBackupUtc();
|
||||
Assert.NotNull(latest);
|
||||
Assert.True(Math.Abs((latest!.Value - stamp).TotalSeconds) < 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user