From 802e0c4cde71c7485c9e034b2a3900c97e24f663 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Tue, 21 Feb 2023 17:32:13 +0100 Subject: [PATCH] Db: Backups --- Pal.Client/Configuration/ConfigurationV7.cs | 1 + .../Configuration/IPalacePalConfiguration.cs | 7 ++ Pal.Client/DependencyInjectionContext.cs | 8 +- Pal.Client/DependencyInjectionLoader.cs | 109 ++++++++++++++++-- 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/Pal.Client/Configuration/ConfigurationV7.cs b/Pal.Client/Configuration/ConfigurationV7.cs index 236fea9..f25f2b5 100644 --- a/Pal.Client/Configuration/ConfigurationV7.cs +++ b/Pal.Client/Configuration/ConfigurationV7.cs @@ -15,6 +15,7 @@ namespace Pal.Client.Configuration public DeepDungeonConfiguration DeepDungeons { get; set; } = new(); public RendererConfiguration Renderer { get; set; } = new(); public List Accounts { get; set; } = new(); + public BackupConfiguration Backups { get; set; } = new(); public IAccountConfiguration CreateAccount(string server, Guid accountId) { diff --git a/Pal.Client/Configuration/IPalacePalConfiguration.cs b/Pal.Client/Configuration/IPalacePalConfiguration.cs index c8a1ee8..848dc36 100644 --- a/Pal.Client/Configuration/IPalacePalConfiguration.cs +++ b/Pal.Client/Configuration/IPalacePalConfiguration.cs @@ -22,6 +22,7 @@ namespace Pal.Client.Configuration DeepDungeonConfiguration DeepDungeons { get; set; } RendererConfiguration Renderer { get; set; } + BackupConfiguration Backups { get; set; } IAccountConfiguration CreateAccount(string server, Guid accountId); IAccountConfiguration? FindAccount(string server); @@ -92,4 +93,10 @@ namespace Pal.Client.Configuration bool EncryptIfNeeded(); } + + public class BackupConfiguration + { + public int MinimumBackupsToKeep { get; set; } = 3; + public int DaysToDeleteAfter { get; set; } = 21; + } } diff --git a/Pal.Client/DependencyInjectionContext.cs b/Pal.Client/DependencyInjectionContext.cs index ac3b722..33d9724 100644 --- a/Pal.Client/DependencyInjectionContext.cs +++ b/Pal.Client/DependencyInjectionContext.cs @@ -1,8 +1,6 @@ -using System; -using System.Globalization; +using System.Globalization; using System.IO; using System.Threading; -using System.Threading.Tasks; using Dalamud.Data; using Dalamud.Game; using Dalamud.Game.ClientState; @@ -11,7 +9,6 @@ using Dalamud.Game.ClientState.Objects; using Dalamud.Game.Command; using Dalamud.Game.Gui; using Dalamud.Interface.Windowing; -using Dalamud.Logging; using Dalamud.Plugin; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -23,7 +20,6 @@ using Pal.Client.Configuration.Legacy; using Pal.Client.Database; using Pal.Client.DependencyInjection; using Pal.Client.DependencyInjection.Logging; -using Pal.Client.Extensions; using Pal.Client.Floors; using Pal.Client.Net; using Pal.Client.Properties; @@ -129,7 +125,7 @@ namespace Pal.Client services.AddScoped(); services.AddScoped(); - // these should maybe be scoped + // rendering services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Pal.Client/DependencyInjectionLoader.cs b/Pal.Client/DependencyInjectionLoader.cs index fce371c..5a0eec1 100644 --- a/Pal.Client/DependencyInjectionLoader.cs +++ b/Pal.Client/DependencyInjectionLoader.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Dalamud.Plugin; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -44,16 +50,11 @@ namespace Pal.Client _logger.LogInformation("Starting async init"); chat = _serviceProvider.GetService(); - // initialize database - await using (var scope = _serviceProvider.CreateAsyncScope()) - { - _logger.LogInformation("Loading database & running migrations"); - await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await RemoveOldBackups(); + await CreateBackups(); + cancellationToken.ThrowIfCancellationRequested(); - // takes 2-3 seconds with initializing connections, loading driver etc. - await dbContext.Database.MigrateAsync(cancellationToken); - _logger.LogInformation("Completed database migrations"); - } + await RunMigrations(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); @@ -91,12 +92,100 @@ namespace Pal.Client catch (Exception e) { _logger.LogError(e, "Async load failed"); - InitCompleted?.Invoke(() => chat?.Error(string.Format(Localization.Error_LoadFailed, $"{e.GetType()} - {e.Message}"))); + InitCompleted?.Invoke(() => + chat?.Error(string.Format(Localization.Error_LoadFailed, $"{e.GetType()} - {e.Message}"))); LoadState = ELoadState.Error; } } + private async Task RemoveOldBackups() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + var pluginInterface = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + + var paths = Directory.GetFiles(pluginInterface.GetPluginConfigDirectory(), "backup-*.data.sqlite3", + new EnumerationOptions + { + IgnoreInaccessible = true, + RecurseSubdirectories = false, + MatchCasing = MatchCasing.CaseSensitive, + AttributesToSkip = FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System, + ReturnSpecialDirectories = false, + }); + if (paths.Length == 0) + return; + + Regex backupRegex = new Regex(@"backup-([\d\-]{10})\.data\.sqlite3", RegexOptions.Compiled); + List<(DateTime Date, string Path)> backupFiles = new(); + foreach (string path in paths) + { + var match = backupRegex.Match(Path.GetFileName(path)); + if (!match.Success) + continue; + + if (DateTime.TryParseExact(match.Groups[1].Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out DateTime backupDate)) + { + backupFiles.Add((backupDate, path)); + } + } + + var toDelete = backupFiles.OrderByDescending(x => x.Date) + .Skip(configuration.Backups.MinimumBackupsToKeep) + .Where(x => (DateTime.Today.ToUniversalTime() - x.Date).Days > configuration.Backups.DaysToDeleteAfter) + .Select(x => x.Path); + foreach (var path in toDelete) + { + try + { + File.Delete(path); + _logger.LogInformation("Deleted old backup file '{Path}'", path); + } + catch (Exception e) + { + _logger.LogWarning(e, "Could not delete backup file '{Path}'", path); + } + } + } + + private async Task CreateBackups() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + + var pluginInterface = scope.ServiceProvider.GetRequiredService(); + string backupPath = Path.Join(pluginInterface.GetPluginConfigDirectory(), + $"backup-{DateTime.Today.ToUniversalTime():yyyy-MM-dd}.data.sqlite3"); + if (!File.Exists(backupPath)) + { + _logger.LogInformation("Creating database backup '{Path}'", backupPath); + + await using var db = scope.ServiceProvider.GetRequiredService(); + await using SqliteConnection source = new(db.Database.GetConnectionString()); + await source.OpenAsync(); + await using SqliteConnection backup = new($"Data Source={backupPath}"); + source.BackupDatabase(backup); + SqliteConnection.ClearPool(backup); + } + else + _logger.LogInformation("Database backup in '{Path}' already exists", backupPath); + } + + private async Task RunMigrations(CancellationToken cancellationToken) + { + // initialize database + await using (var scope = _serviceProvider.CreateAsyncScope()) + { + _logger.LogInformation("Loading database & running migrations"); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + + // takes 2-3 seconds with initializing connections, loading driver etc. + await dbContext.Database.MigrateAsync(cancellationToken); + _logger.LogInformation("Completed database migrations"); + } + } + public enum ELoadState { Initializing,