diff --git a/Elwig/App.xaml.cs b/Elwig/App.xaml.cs index 953f83b..356e6d5 100644 --- a/Elwig/App.xaml.cs +++ b/Elwig/App.xaml.cs @@ -28,6 +28,8 @@ namespace Elwig { public static bool ForceShutdown { get; private set; } = false; private readonly DispatcherTimer _autoUpdateTimer = new() { Interval = TimeSpan.FromHours(1) }; + private readonly DispatcherTimer _autoBackupTimer = new() { Interval = TimeSpan.FromHours(1) }; + private DatabaseBackupService? _databaseBackupService; public readonly SerialPortWatcher SerialPortWatcher = new(); public static readonly string DataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Elwig"); @@ -101,6 +103,13 @@ namespace Elwig { return; } + if (Config.DatabaseBackup) { + _databaseBackupService = new DatabaseBackupService(Config.DatabaseFile); + await _databaseBackupService.RunIfNeededAsync(); + _autoBackupTimer.Tick += OnAutoBackupTimer; + _autoBackupTimer.Start(); + } + LastChanged = CurrentLastWrite; ContextTimer.Start(); @@ -191,6 +200,7 @@ namespace Elwig { } private async void Application_Exit(object sender, ExitEventArgs evt) { + _autoBackupTimer.Stop(); SerialPortWatcher.Dispose(); foreach (var s in EventScales) { s.Dispose(); @@ -198,6 +208,12 @@ namespace Elwig { await Pdf.Cleanup(); } + private async void OnAutoBackupTimer(object? sender, EventArgs evt) { + if (_databaseBackupService != null) { + await _databaseBackupService.RunIfNeededAsync(); + } + } + public static void SetBranch(Branch b) { SetBranch((b.ZwstId, b.Name, b.PostalDest?.AtPlz?.Plz, b.PostalDest?.AtPlz?.Ort.Name, b.Address, b.PhoneNr, b.FaxNr, b.MobileNr)); } diff --git a/Elwig/Helpers/Config.cs b/Elwig/Helpers/Config.cs index c0afe05..0fdcfc7 100644 --- a/Elwig/Helpers/Config.cs +++ b/Elwig/Helpers/Config.cs @@ -42,6 +42,7 @@ namespace Elwig.Helpers { public bool Debug; public string DatabaseFile = App.DataPath + "database.sqlite3"; + public bool DatabaseBackup = true; public string? DatabaseLog = null; public string? Branch = null; public WeighingMode? WeighingMode; @@ -76,6 +77,8 @@ namespace Elwig.Helpers { var config = new ConfigurationBuilder().AddIniFile(FileName).Build(); DatabaseFile = Path.Combine(Path.GetDirectoryName(FileName) ?? App.DataPath, config["database:file"] ?? "database.sqlite3"); + var backup = config["database:backup"]; + DatabaseBackup = backup == null || TrueValues.Contains(backup.ToLower()); var log = config["database:log"]; DatabaseLog = log != null ? Path.Combine(Path.GetDirectoryName(FileName) ?? App.DataPath, log) : null; Branch = config["general:branch"]; diff --git a/Elwig/Helpers/DatabaseBackupService.cs b/Elwig/Helpers/DatabaseBackupService.cs new file mode 100644 index 0000000..d70a93a --- /dev/null +++ b/Elwig/Helpers/DatabaseBackupService.cs @@ -0,0 +1,201 @@ +using Microsoft.Data.Sqlite; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Elwig.Helpers { + internal sealed class DatabaseBackupService { + + private const int DailyBackupCount = 7; + private const int WeeklyBackupCount = 5; + + private readonly string DatabaseFile; + private readonly string BackupDirectory; + private readonly string DatabaseName; + private readonly string BackupPrefix; + private readonly string LockFile; + private readonly Func Now; + private readonly SemaphoreSlim Gate = new(1, 1); + + internal DatabaseBackupService(string databaseFile, Func? now = null) { + DatabaseFile = Path.GetFullPath(databaseFile); + BackupDirectory = Path.Combine(Path.GetDirectoryName(DatabaseFile)!, "backup"); + DatabaseName = Path.GetFileNameWithoutExtension(DatabaseFile); + BackupPrefix = $"{DatabaseName}_"; + LockFile = Path.Combine(BackupDirectory, $".{DatabaseName}.backup.lock"); + Now = now ?? (() => DateTime.Now); + } + + internal async Task RunIfNeededAsync() { + if (!await Gate.WaitAsync(0)) return; + try { + var slotDate = GetSlotDate(Now()); + await Task.Run(() => Run(slotDate)); + } catch { + // Automatic backups dont prevent Elwig from starting or running. + } finally { + Gate.Release(); + } + } + + private void Run(DateOnly slotDate) { + Directory.CreateDirectory(BackupDirectory); + + FileStream lockStream; + try { + lockStream = new FileStream(LockFile, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } catch (IOException) { + // Another Elwig instance is currently creating/pruning backups. + return; + } + + using (lockStream) { + CleanupTemporaryFiles(); + + var backupFile = GetBackupFile(slotDate); + if (!File.Exists(backupFile)) { + CreateBackup(backupFile); + } + + if (File.Exists(backupFile)) { + PruneBackups(slotDate); + } + } + } + + private void CreateBackup(string backupFile) { + var id = Guid.NewGuid().ToString("N"); + var temporaryDatabase = Path.Combine(BackupDirectory, $".{DatabaseName}.backup-{id}.sqlite3.tmp"); + var temporaryArchive = Path.Combine(BackupDirectory, $".{DatabaseName}.backup-{id}.zip.tmp"); + + try { + var sourceConnectionString = new SqliteConnectionStringBuilder { + DataSource = DatabaseFile, + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Default, + Pooling = false, + }.ToString(); + var destinationConnectionString = new SqliteConnectionStringBuilder { + DataSource = temporaryDatabase, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Default, + Pooling = false, + }.ToString(); + + using (var source = new SqliteConnection(sourceConnectionString)) + using (var destination = new SqliteConnection(destinationConnectionString)) { + source.Open(); + destination.Open(); + source.BackupDatabase(destination); + + using var check = destination.CreateCommand(); + check.CommandText = "PRAGMA quick_check"; + if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.OrdinalIgnoreCase)) { + throw new InvalidDataException("The automatic database backup failed its SQLite integrity check."); + } + } + + using (var archive = ZipFile.Open(temporaryArchive, ZipArchiveMode.Create)) { + archive.CreateEntryFromFile(temporaryDatabase, "database.sqlite3", CompressionLevel.SmallestSize); + } + + try { + File.Move(temporaryArchive, backupFile, false); + } catch (IOException) when (File.Exists(backupFile)) { + // A completed backup won a publish race. Its time slot is satisfied. + } + } finally { + DeleteFile(temporaryDatabase); + DeleteFile(temporaryArchive); + } + } + + private void CleanupTemporaryFiles() { + foreach (var file in Directory.EnumerateFiles(BackupDirectory, $".{DatabaseName}.backup-*.tmp")) { + DeleteFile(file); + } + } + + internal void PruneBackups(DateOnly slotDate) { + var backups = GetManagedBackups().ToArray(); + var retained = new HashSet(StringComparer.OrdinalIgnoreCase); + + var dailyStart = slotDate.AddDays(-(DailyBackupCount - 1)); + foreach (var backup in backups.Where(b => b.Date >= dailyStart && b.Date <= slotDate)) { + retained.Add(backup.File); + } + + var daysSinceMonday = ((int)slotDate.DayOfWeek + 6) % 7; + var currentWeekStart = slotDate.AddDays(-daysSinceMonday); + for (var i = 1; i <= WeeklyBackupCount; i++) { + var weekStart = currentWeekStart.AddDays(-7 * i); + RetainNewest(backups, retained, weekStart, weekStart.AddDays(7)); + } + + var currentMonthStart = new DateOnly(slotDate.Year, slotDate.Month, 1); + foreach (var month in backups + .Where(b => b.Date < currentMonthStart) + .GroupBy(b => (b.Date.Year, b.Date.Month))) { + retained.Add(month.OrderByDescending(b => b.Date).First().File); + } + + // Clock corrections should not cause future-dated backups to be deleted. + foreach (var backup in backups.Where(b => b.Date > slotDate)) { + retained.Add(backup.File); + } + + foreach (var backup in backups.Where(b => !retained.Contains(b.File))) { + DeleteFile(backup.File); + } + } + + private static void RetainNewest( + IEnumerable<(string File, DateOnly Date)> backups, + ISet retained, + DateOnly start, + DateOnly end + ) { + var newest = backups + .Where(b => b.Date >= start && b.Date < end) + .OrderByDescending(b => b.Date) + .FirstOrDefault(); + if (newest.File != null) retained.Add(newest.File); + } + + private IEnumerable<(string File, DateOnly Date)> GetManagedBackups() { + foreach (var file in Directory.EnumerateFiles(BackupDirectory, $"{BackupPrefix}*.sqlite3.zip")) { + var name = Path.GetFileName(file); + if (!name.StartsWith(BackupPrefix, StringComparison.OrdinalIgnoreCase) || + !name.EndsWith(".sqlite3.zip", StringComparison.OrdinalIgnoreCase)) { + continue; + } + + var dateText = name[BackupPrefix.Length..^".sqlite3.zip".Length]; + if (DateOnly.TryParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { + yield return (file, date); + } + } + } + + private string GetBackupFile(DateOnly date) { + return Path.Combine(BackupDirectory, $"{BackupPrefix}{date:yyyy-MM-dd}.sqlite3.zip"); + } + + private static void DeleteFile(string file) { + try { + File.Delete(file); + } catch { + // Cleanup and retention are best-effort and will be retried later. + } + } + + internal static DateOnly GetSlotDate(DateTime timestamp) { + return DateOnly.FromDateTime(timestamp.Hour >= 3 ? timestamp : timestamp.AddDays(-1)); + } + } +} diff --git a/Installer/Files/config.ini b/Installer/Files/config.ini index 6db0edf..860e5f8 100644 --- a/Installer/Files/config.ini +++ b/Installer/Files/config.ini @@ -7,6 +7,8 @@ [database] ; Relative or absolute path to database file file = database.sqlite3 +; Enables automatic compressed database backups +backup = true ; Enables database logging ;log = db.log