Add declarative backups for migrations (#14135)

This commit is contained in:
JPVenson
2025-06-04 01:49:41 +03:00
committed by GitHub
parent 0c46431cbb
commit d5672ce407
15 changed files with 377 additions and 116 deletions

View File

@@ -16,6 +16,7 @@ using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.FullSystemBackup;
@@ -31,7 +32,7 @@ public class BackupService : IBackupService
private readonly IServerApplicationHost _applicationHost;
private readonly IServerApplicationPaths _applicationPaths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly ISystemManager _systemManager;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
AllowTrailingCommas = true,
@@ -48,21 +49,21 @@ public class BackupService : IBackupService
/// <param name="applicationHost">The Application host.</param>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
/// <param name="systemManager">The SystemManager.</param>
/// <param name="applicationLifetime">The SystemManager.</param>
public BackupService(
ILogger<BackupService> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost applicationHost,
IServerApplicationPaths applicationPaths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
ISystemManager systemManager)
IHostApplicationLifetime applicationLifetime)
{
_logger = logger;
_dbProvider = dbProvider;
_applicationHost = applicationHost;
_applicationPaths = applicationPaths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_systemManager = systemManager;
_hostApplicationLifetime = applicationLifetime;
}
/// <inheritdoc/>
@@ -71,7 +72,11 @@ public class BackupService : IBackupService
_applicationHost.RestoreBackupPath = archivePath;
_applicationHost.ShouldRestart = true;
_applicationHost.NotifyPendingRestart();
_systemManager.Restart();
_ = Task.Run(async () =>
{
await Task.Delay(500).ConfigureAwait(false);
_hostApplicationLifetime.StopApplication();
});
}
/// <inheritdoc/>
@@ -136,87 +141,90 @@ public class BackupService : IBackupService
CopyDirectory(_applicationPaths.DataPath, "Data/");
CopyDirectory(_applicationPaths.RootFolderPath, "Root/");
_logger.LogInformation("Begin restoring Database");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
if (manifest.Options.Database)
{
// restore migration history manually
var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
if (historyEntry is null)
_logger.LogInformation("Begin restoring Database");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
HistoryRow[] historyEntries;
var historyArchive = historyEntry.Open();
await using (historyArchive.ConfigureAwait(false))
{
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
var historyRepository = dbContext.GetService<IHistoryRepository>();
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
foreach (var item in historyEntries)
{
var insertScript = historyRepository.GetInsertScript(item);
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
}
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
.ToArray();
var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
_logger.LogInformation("Begin purging database");
await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
_logger.LogInformation("Database Purged");
foreach (var entityType in entityTypes)
{
_logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
if (zipEntry is null)
// restore migration history manually
var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
if (historyEntry is null)
{
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
continue;
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
var zipEntryStream = zipEntry.Open();
await using (zipEntryStream.ConfigureAwait(false))
HistoryRow[] historyEntries;
var historyArchive = historyEntry.Open();
await using (historyArchive.ConfigureAwait(false))
{
_logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
var records = 0;
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
{
var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
if (entity is null)
{
throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
}
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
try
{
records++;
dbContext.Add(entity);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
}
var historyRepository = dbContext.GetService<IHistoryRepository>();
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
foreach (var item in historyEntries)
{
var insertScript = historyRepository.GetInsertScript(item);
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
}
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
.ToArray();
var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
_logger.LogInformation("Begin purging database");
await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
_logger.LogInformation("Database Purged");
foreach (var entityType in entityTypes)
{
_logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
if (zipEntry is null)
{
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
continue;
}
_logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
}
}
var zipEntryStream = zipEntry.Open();
await using (zipEntryStream.ConfigureAwait(false))
{
_logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
var records = 0;
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
{
var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
if (entity is null)
{
throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
}
_logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogInformation("Restored database.");
try
{
records++;
dbContext.Add(entity);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
}
}
_logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
}
}
_logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogInformation("Restored database.");
}
}
_logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
@@ -486,7 +494,8 @@ public class BackupService : IBackupService
{
Metadata = options.Metadata,
Subtitles = options.Subtitles,
Trickplay = options.Trickplay
Trickplay = options.Trickplay,
Database = options.Database
};
}
@@ -496,7 +505,8 @@ public class BackupService : IBackupService
{
Metadata = options.Metadata,
Subtitles = options.Subtitles,
Trickplay = options.Trickplay
Trickplay = options.Trickplay,
Database = options.Database
};
}
}