Sfoglia il codice sorgente

Feature/backup on migration (#13754)

* Added generalised backup for migrations

* Added backup strategy to MigrateLibraryDb

* Added missing namespace

* Fix merge issues

* Fixed style issue

* change fast backup key to timestamp

* Update src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs

* Update Fields

* applied review comments
JPVenson 2 mesi fa
parent
commit
296b17bf44

+ 12 - 0
Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs

@@ -0,0 +1,12 @@
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Defines a migration that operates on the Database.
+/// </summary>
+internal interface IDatabaseMigrationRoutine : IMigrationRoutine
+{
+}

+ 2 - 0
Jellyfin.Server/Migrations/IMigrationRoutine.cs

@@ -1,4 +1,6 @@
 using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore.Internal;
 
 namespace Jellyfin.Server.Migrations
 {

+ 55 - 24
Jellyfin.Server/Migrations/MigrationRunner.cs

@@ -2,10 +2,15 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
 using Emby.Server.Implementations;
 using Emby.Server.Implementations.Serialization;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore.Storage;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 
@@ -57,7 +62,8 @@ namespace Jellyfin.Server.Migrations
         /// </summary>
         /// <param name="host">CoreAppHost that hosts current version.</param>
         /// <param name="loggerFactory">Factory for making the logger.</param>
-        public static void Run(CoreAppHost host, ILoggerFactory loggerFactory)
+        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+        public static async Task Run(CoreAppHost host, ILoggerFactory loggerFactory)
         {
             var logger = loggerFactory.CreateLogger<MigrationRunner>();
             var migrations = _migrationTypes
@@ -67,7 +73,8 @@ namespace Jellyfin.Server.Migrations
 
             var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey);
             HandleStartupWizardCondition(migrations, migrationOptions, host.ConfigurationManager.Configuration.IsStartupWizardCompleted, logger);
-            PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger);
+            await PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger, host.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>())
+                .ConfigureAwait(false);
         }
 
         /// <summary>
@@ -75,7 +82,8 @@ namespace Jellyfin.Server.Migrations
         /// </summary>
         /// <param name="appPaths">Application paths.</param>
         /// <param name="loggerFactory">Factory for making the logger.</param>
-        public static void RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory)
+        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+        public static async Task RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory)
         {
             var logger = loggerFactory.CreateLogger<MigrationRunner>();
             var migrations = _preStartupMigrationTypes
@@ -95,7 +103,7 @@ namespace Jellyfin.Server.Migrations
                 : new ServerConfiguration();
 
             HandleStartupWizardCondition(migrations, migrationOptions, serverConfig.IsStartupWizardCompleted, logger);
-            PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger);
+            await PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger, null).ConfigureAwait(false);
         }
 
         private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger)
@@ -111,38 +119,61 @@ namespace Jellyfin.Server.Migrations
             migrationOptions.Applied.AddRange(onlyOldInstalls.Select(m => (m.Id, m.Name)));
         }
 
-        private static void PerformMigrations(IMigrationRoutine[] migrations, MigrationOptions migrationOptions, Action<MigrationOptions> saveConfiguration, ILogger logger)
+        private static async Task PerformMigrations(
+            IMigrationRoutine[] migrations,
+            MigrationOptions migrationOptions,
+            Action<MigrationOptions> saveConfiguration,
+            ILogger logger,
+            IJellyfinDatabaseProvider? jellyfinDatabaseProvider)
         {
             // save already applied migrations, and skip them thereafter
             saveConfiguration(migrationOptions);
             var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet();
+            var migrationsToBeApplied = migrations.Where(e => !appliedMigrationIds.Contains(e.Id)).ToArray();
 
-            for (var i = 0; i < migrations.Length; i++)
+            string? migrationKey = null;
+            if (jellyfinDatabaseProvider is not null && migrationsToBeApplied.Any(f => f is IDatabaseMigrationRoutine))
             {
-                var migrationRoutine = migrations[i];
-                if (appliedMigrationIds.Contains(migrationRoutine.Id))
-                {
-                    logger.LogDebug("Skipping migration '{Name}' since it is already applied", migrationRoutine.Name);
-                    continue;
-                }
-
-                logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name);
-
+                logger.LogInformation("Performing database backup");
                 try
                 {
-                    migrationRoutine.Perform();
+                    migrationKey = await jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false);
+                    logger.LogInformation("Database backup with key '{BackupKey}' has been successfully created.", migrationKey);
                 }
-                catch (Exception ex)
+                catch (NotImplementedException)
                 {
-                    logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name);
-                    throw;
+                    logger.LogWarning("Could not perform backup of database before migration because provider does not support it");
                 }
+            }
+
+            try
+            {
+                foreach (var migrationRoutine in migrationsToBeApplied)
+                {
+                    logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name);
+
+                    try
+                    {
+                        migrationRoutine.Perform();
+                    }
+                    catch (Exception ex)
+                    {
+                        logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name);
+                        throw;
+                    }
 
-                // Mark the migration as completed
-                logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name);
-                migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name));
-                saveConfiguration(migrationOptions);
-                logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name);
+                    // Mark the migration as completed
+                    logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name);
+                    migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name));
+                    saveConfiguration(migrationOptions);
+                    logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name);
+                }
+            }
+            catch (System.Exception) when (migrationKey is not null && jellyfinDatabaseProvider is not null)
+            {
+                logger.LogInformation("Rollback on database as migration reported failure.");
+                await jellyfinDatabaseProvider.RestoreBackupFast(migrationKey, CancellationToken.None).ConfigureAwait(false);
+                throw;
             }
         }
     }

+ 1 - 1
Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

@@ -29,7 +29,7 @@ namespace Jellyfin.Server.Migrations.Routines;
 /// <summary>
 /// The migration routine for migrating the userdata database to EF Core.
 /// </summary>
-public class MigrateLibraryDb : IMigrationRoutine
+internal class MigrateLibraryDb : IDatabaseMigrationRoutine
 {
     private const string DbFilename = "library.db";
 

+ 2 - 2
Jellyfin.Server/Program.cs

@@ -121,7 +121,7 @@ namespace Jellyfin.Server
             }
 
             StartupHelpers.PerformStaticInitialization();
-            Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory);
+            await Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory).ConfigureAwait(false);
 
             do
             {
@@ -166,7 +166,7 @@ namespace Jellyfin.Server
                 appHost.ServiceProvider = _jellyfinHost.Services;
 
                 await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
-                Migrations.MigrationRunner.Run(appHost, _loggerFactory);
+                await Migrations.MigrationRunner.Run(appHost, _loggerFactory).ConfigureAwait(false);
 
                 try
                 {

+ 17 - 0
src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs

@@ -1,3 +1,4 @@
+using System;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.EntityFrameworkCore;
@@ -45,4 +46,20 @@ public interface IJellyfinDatabaseProvider
     /// <param name="cancellationToken">The token that will be used to abort the operation.</param>
     /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
     Task RunShutdownTask(CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Runs a full Database backup that can later be restored to.
+    /// </summary>
+    /// <param name="cancellationToken">A cancelation token.</param>
+    /// <returns>A key to identify the backup.</returns>
+    /// <exception cref="NotImplementedException">May throw an NotImplementException if this operation is not supported for this database.</exception>
+    Task<string> MigrationBackupFast(CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Restores a backup that has been previously created by <see cref="MigrationBackupFast(CancellationToken)"/>.
+    /// </summary>
+    /// <param name="key">The key to the backup from which the current database should be restored from.</param>
+    /// <param name="cancellationToken">A cancelation token.</param>
+    /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
+    Task RestoreBackupFast(string key, CancellationToken cancellationToken);
 }

+ 34 - 0
src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Globalization;
 using System.IO;
 using System.Threading;
 using System.Threading.Tasks;
@@ -16,6 +17,7 @@ namespace Jellyfin.Database.Providers.Sqlite;
 [JellyfinDatabaseProviderKey("Jellyfin-SQLite")]
 public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
 {
+    private const string BackupFolderName = "SQLiteBackups";
     private readonly IApplicationPaths _applicationPaths;
     private readonly ILogger<SqliteDatabaseProvider> _logger;
 
@@ -84,4 +86,36 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
     {
         configurationBuilder.Conventions.Add(_ => new DoNotUseReturningClauseConvention());
     }
+
+    /// <inheritdoc />
+    public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
+    {
+        var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture);
+        var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
+        var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName);
+        if (!Directory.Exists(backupFile))
+        {
+            Directory.CreateDirectory(backupFile);
+        }
+
+        backupFile = Path.Combine(_applicationPaths.DataPath, $"{key}_jellyfin.db");
+        File.Copy(path, backupFile);
+        return Task.FromResult(key);
+    }
+
+    /// <inheritdoc />
+    public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
+    {
+        var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
+        var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db");
+
+        if (!File.Exists(backupFile))
+        {
+            _logger.LogCritical("Tried to restore a backup that does not exist.");
+            return Task.CompletedTask;
+        }
+
+        File.Copy(backupFile, path, true);
+        return Task.CompletedTask;
+    }
 }