Compare commits

...
1 Commits
Author SHA1 Message Date
thomas.hilscher 70e1e7c73a Add automatic database backups
Test / Run tests (push) Successful in 2m46s
2026-08-24 20:52:50 +02:00
4 changed files with 222 additions and 0 deletions
+16
View File
@@ -28,6 +28,8 @@ namespace Elwig {
public static bool ForceShutdown { get; private set; } = false; public static bool ForceShutdown { get; private set; } = false;
private readonly DispatcherTimer _autoUpdateTimer = new() { Interval = TimeSpan.FromHours(1) }; 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 readonly SerialPortWatcher SerialPortWatcher = new();
public static readonly string DataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Elwig"); public static readonly string DataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Elwig");
@@ -101,6 +103,13 @@ namespace Elwig {
return; return;
} }
if (Config.DatabaseBackup) {
_databaseBackupService = new DatabaseBackupService(Config.DatabaseFile);
await _databaseBackupService.RunIfNeededAsync();
_autoBackupTimer.Tick += OnAutoBackupTimer;
_autoBackupTimer.Start();
}
LastChanged = CurrentLastWrite; LastChanged = CurrentLastWrite;
ContextTimer.Start(); ContextTimer.Start();
@@ -191,6 +200,7 @@ namespace Elwig {
} }
private async void Application_Exit(object sender, ExitEventArgs evt) { private async void Application_Exit(object sender, ExitEventArgs evt) {
_autoBackupTimer.Stop();
SerialPortWatcher.Dispose(); SerialPortWatcher.Dispose();
foreach (var s in EventScales) { foreach (var s in EventScales) {
s.Dispose(); s.Dispose();
@@ -198,6 +208,12 @@ namespace Elwig {
await Pdf.Cleanup(); await Pdf.Cleanup();
} }
private async void OnAutoBackupTimer(object? sender, EventArgs evt) {
if (_databaseBackupService != null) {
await _databaseBackupService.RunIfNeededAsync();
}
}
public static void SetBranch(Branch b) { 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)); SetBranch((b.ZwstId, b.Name, b.PostalDest?.AtPlz?.Plz, b.PostalDest?.AtPlz?.Ort.Name, b.Address, b.PhoneNr, b.FaxNr, b.MobileNr));
} }
+3
View File
@@ -42,6 +42,7 @@ namespace Elwig.Helpers {
public bool Debug; public bool Debug;
public string DatabaseFile = App.DataPath + "database.sqlite3"; public string DatabaseFile = App.DataPath + "database.sqlite3";
public bool DatabaseBackup = true;
public string? DatabaseLog = null; public string? DatabaseLog = null;
public string? Branch = null; public string? Branch = null;
public WeighingMode? WeighingMode; public WeighingMode? WeighingMode;
@@ -76,6 +77,8 @@ namespace Elwig.Helpers {
var config = new ConfigurationBuilder().AddIniFile(FileName).Build(); var config = new ConfigurationBuilder().AddIniFile(FileName).Build();
DatabaseFile = Path.Combine(Path.GetDirectoryName(FileName) ?? App.DataPath, config["database:file"] ?? "database.sqlite3"); 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"]; var log = config["database:log"];
DatabaseLog = log != null ? Path.Combine(Path.GetDirectoryName(FileName) ?? App.DataPath, log) : null; DatabaseLog = log != null ? Path.Combine(Path.GetDirectoryName(FileName) ?? App.DataPath, log) : null;
Branch = config["general:branch"]; Branch = config["general:branch"];
+201
View File
@@ -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<DateTime> Now;
private readonly SemaphoreSlim Gate = new(1, 1);
internal DatabaseBackupService(string databaseFile, Func<DateTime>? 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<string>(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<string> 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));
}
}
}
+2
View File
@@ -7,6 +7,8 @@
[database] [database]
; Relative or absolute path to database file ; Relative or absolute path to database file
file = database.sqlite3 file = database.sqlite3
; Enables automatic compressed database backups
backup = true
; Enables database logging ; Enables database logging
;log = db.log ;log = db.log