Added a restore callback module
This PR adds a new type of module for integrating into the restore flow. With this interface a module can register itself for restore notifications and be invoked while certain events happen during the restore process.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Copyright (C) 2026, The Duplicati Team
|
||||
// https://duplicati.com, hello@duplicati.com
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Duplicati.Library.Interface
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for implementing restore-specific callback modules.
|
||||
///
|
||||
/// Modules implementing this interface are notified at key points during a
|
||||
/// restore operation: when the priority-files list is being prepared (allowing
|
||||
/// the module to modify it), when the bulk restore starts (after all priority
|
||||
/// files have been restored), and for each individual file that is restored.
|
||||
///
|
||||
/// The per-file callback (<see cref="OnFileRestoredAsync"/>) is only invoked when
|
||||
/// the new restore engine is used (i.e. when <c>--restore-legacy</c> is not
|
||||
/// set). The <see cref="OnPreparePriorityFilesAsync"/> and
|
||||
/// <see cref="OnBulkRestoreStartAsync"/> callbacks are invoked for both the legacy
|
||||
/// and the new restore engines.
|
||||
/// </summary>
|
||||
public interface IRestoreCallbackModule : IGenericModule
|
||||
{
|
||||
/// <summary>
|
||||
/// Called when the priority-files list has been collected from the restore
|
||||
/// destination, but before any files are restored. The module is free to
|
||||
/// inspect and modify the list in place (add, remove or reorder entries)
|
||||
/// to influence which files are restored first.
|
||||
/// </summary>
|
||||
/// <param name="priorityFiles">The list of priority file names. The module may modify this list.</param>
|
||||
/// <param name="version">The 0-based backup version index being restored (0 = newest).</param>
|
||||
/// <param name="backupTimestamp">The timestamp of the backup version being restored, in UTC.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task OnPreparePriorityFilesAsync(IList<string> priorityFiles, long version, DateTime backupTimestamp, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Called once all priority files have been restored and the bulk restore
|
||||
/// of the remaining files is about to start. If there are no priority
|
||||
/// files, this is called before the single bulk restore pass begins.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task OnBulkRestoreStartAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Called for each restore target that has been restored, with the backup version
|
||||
/// index (0 being the newest backup) the target was restored from, the
|
||||
/// timestamp of that backup version, and the target path. The callback fires for
|
||||
/// regular files as well as folders (whose metadata is restored) and alternate data
|
||||
/// streams; implementations that only care about regular files should filter by
|
||||
/// path themselves. This is only invoked when the new restore engine is used (i.e.
|
||||
/// <c>--restore-legacy</c> is not set).
|
||||
/// </summary>
|
||||
/// <param name="version">The 0-based backup version index the target was restored from (0 = newest).</param>
|
||||
/// <param name="path">The target path of the restored file, folder or alternate data stream.</param>
|
||||
/// <param name="backupTimestamp">The timestamp of the backup version the target was restored from, in UTC.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task OnFileRestoredAsync(long version, string path, DateTime backupTimestamp, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CoCoL;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Main.Database.Local;
|
||||
using Duplicati.Library.Common.IO;
|
||||
|
||||
@@ -51,8 +52,11 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
/// <param name="db">The restore database, which is queried for the file list.</param>
|
||||
/// <param name="options">The restore options</param>
|
||||
/// <param name="result">The restore results</param>
|
||||
/// <param name="restoreDestination">The restore destination provider, used to get priority files.</param>
|
||||
public static Task RunAsync(Channels channels, LocalRestoreDatabase db, Options options, RestoreResults result, IList<string> priorityFiles)
|
||||
/// <param name="priorityFiles">The list of priority file names to include.</param>
|
||||
/// <param name="version">The 0-based backup version index being restored (0 = newest), reported to restore callback modules.</param>
|
||||
/// <param name="backupTimestamp">The timestamp of the backup version being restored, in UTC, reported to restore callback modules.</param>
|
||||
/// <param name="modules">The loaded generic modules, used to dispatch the bulk-restore-start callback. May be null.</param>
|
||||
public static Task RunAsync(Channels channels, LocalRestoreDatabase db, Options options, RestoreResults result, IList<string> priorityFiles, long version, DateTime backupTimestamp, IEnumerable<IGenericModule>? modules)
|
||||
{
|
||||
return AutomationExtensions.RunTask(
|
||||
new
|
||||
@@ -102,31 +106,51 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
|
||||
sw_write_file?.Start();
|
||||
|
||||
// First, send priority files (if any) to be restored first
|
||||
// Resolve which files are priority files. A priority entry only counts
|
||||
// if it matches at least one restorable target; entries a restore callback
|
||||
// module added that match nothing are ignored so the priority-file counter
|
||||
// below stays consistent with the number of priority FileRequests actually
|
||||
// emitted. Without this, a non-matching entry would leave the non-priority
|
||||
// FileProcessors waiting forever for a priority file that never arrives.
|
||||
List<FileRequest> priorityFileList;
|
||||
IEnumerable<FileRequest> remainingFiles;
|
||||
if (priorityFiles.Count > 0)
|
||||
{
|
||||
var priorityFileSet = new HashSet<string>(priorityFiles, StringComparer.OrdinalIgnoreCase);
|
||||
var priorityFileList = files.Where(f => priorityFileSet.Any(pf => f.TargetPath.EndsWith(pf, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
var remainingFiles = files.Where(f => !priorityFileSet.Any(pf => f.TargetPath.EndsWith(pf, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
|
||||
// Send priority files first (marked with IsPriorityFile=true)
|
||||
foreach (var file in priorityFileList)
|
||||
{
|
||||
var priorityFileRequest = new FileRequest(file.ID, file.OriginalPath, file.TargetPath, file.Hash, file.Length, file.BlocksetID, IsPriorityFile: true);
|
||||
await self.Output.WriteAsync(priorityFileRequest).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Then send remaining files
|
||||
foreach (var file in remainingFiles)
|
||||
await self.Output.WriteAsync(file).ConfigureAwait(false);
|
||||
priorityFileList = files.Where(f => priorityFileSet.Any(pf => f.TargetPath.EndsWith(pf, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
remainingFiles = files.Where(f => !priorityFileSet.Any(pf => f.TargetPath.EndsWith(pf, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
else
|
||||
{
|
||||
// No priority files, send all files in order
|
||||
foreach (var file in files)
|
||||
await self.Output.WriteAsync(file).ConfigureAwait(false);
|
||||
priorityFileList = new List<FileRequest>();
|
||||
remainingFiles = files;
|
||||
}
|
||||
|
||||
// The priority-file counter must reflect the number of priority
|
||||
// FileRequests actually emitted below, not the size of the (module-mutable)
|
||||
// priority-files list. Resetting the completion source here (the producer)
|
||||
// is safe: the FileProcessors only await it for non-priority files, which are
|
||||
// emitted afterwards, and they cannot read a file until it has been written.
|
||||
FileProcessor.priority_files_remaining = priorityFileList.Count;
|
||||
FileProcessor.priority_files_completed = new TaskCompletionSource();
|
||||
|
||||
// When no priority files will be processed, the bulk restore starts
|
||||
// immediately; notify restore callback modules now. When priority files
|
||||
// exist, the FileProcessor that finishes the last one notifies the modules.
|
||||
if (priorityFileList.Count == 0)
|
||||
await RestoreHandler.InvokeBulkRestoreStartAsync(modules, result.TaskControl.ProgressToken).ConfigureAwait(false);
|
||||
|
||||
// Send priority files first (marked with IsPriorityFile=true)
|
||||
foreach (var file in priorityFileList)
|
||||
{
|
||||
var priorityFileRequest = new FileRequest(file.ID, file.OriginalPath, file.TargetPath, file.Hash, file.Length, file.BlocksetID, IsPriorityFile: true, Version: version, BackupTimestamp: backupTimestamp);
|
||||
await self.Output.WriteAsync(priorityFileRequest).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Then send remaining files
|
||||
foreach (var file in remainingFiles)
|
||||
await self.Output.WriteAsync(file.WithVersion(version, backupTimestamp)).ConfigureAwait(false);
|
||||
|
||||
sw_write_file?.Stop();
|
||||
|
||||
if (!options.SkipMetadata)
|
||||
@@ -141,14 +165,14 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
|
||||
sw_write_folder?.Start();
|
||||
foreach (var folder in folders)
|
||||
await self.Output.WriteAsync(folder).ConfigureAwait(false);
|
||||
await self.Output.WriteAsync(folder.WithVersion(version, backupTimestamp)).ConfigureAwait(false);
|
||||
sw_write_folder?.Stop();
|
||||
}
|
||||
|
||||
// Send the alternate data streams last, so their hosts are restored
|
||||
sw_write_file?.Start();
|
||||
foreach (var file in adsStreams)
|
||||
await self.Output.WriteAsync(new FileRequest(file.ID, file.OriginalPath, file.TargetPath, file.Hash, file.Length, file.BlocksetID, IsAlternateDataStream: true)).ConfigureAwait(false);
|
||||
await self.Output.WriteAsync(new FileRequest(file.ID, file.OriginalPath, file.TargetPath, file.Hash, file.Length, file.BlocksetID, IsAlternateDataStream: true, Version: version, BackupTimestamp: backupTimestamp)).ConfigureAwait(false);
|
||||
sw_write_file?.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -80,7 +80,8 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
/// <param name="block_response">The channel to receive blocks from the block manager.</param>
|
||||
/// <param name="options">The restore options.</param>
|
||||
/// <param name="results">The restore results.</param>
|
||||
public static Task RunAsync(Channels channels, LocalRestoreDatabase db, IChannel<BlockRequest> block_request, IChannel<Task<DataBlock>> block_response, IRestoreDestinationProvider restoreDestination, Options options, RestoreResults results)
|
||||
/// <param name="modules">The loaded generic modules, used to dispatch restore callbacks. May be null.</param>
|
||||
public static Task RunAsync(Channels channels, LocalRestoreDatabase db, IChannel<BlockRequest> block_request, IChannel<Task<DataBlock>> block_response, IRestoreDestinationProvider restoreDestination, Options options, RestoreResults results, IEnumerable<IGenericModule>? modules)
|
||||
{
|
||||
return AutomationExtensions.RunTask(
|
||||
new
|
||||
@@ -125,7 +126,7 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
sw_file?.Stop();
|
||||
|
||||
// Check if this is a priority file and wait for all priority files to complete before processing non-priority files
|
||||
await WaitForPriorityFilesIfNeededAsync(file).ConfigureAwait(false);
|
||||
await WaitForPriorityFilesIfNeededAsync(file, modules, results.TaskControl.ProgressToken).ConfigureAwait(false);
|
||||
|
||||
Logging.Log.WriteExplicitMessage(LOGTAG, "FileRestored", null, "{0} Restoring file {1}", my_id, file.TargetPath);
|
||||
|
||||
@@ -548,6 +549,8 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
}
|
||||
sw_work_results?.Stop();
|
||||
|
||||
await RestoreHandler.InvokeFileRestoredAsync(modules, file.Version, file.TargetPath, file.BackupTimestamp, results.TaskControl.ProgressToken).ConfigureAwait(false);
|
||||
|
||||
Logging.Log.WriteVerboseMessage(LOGTAG, "RestoredFile", $"{my_id} Restored file {{0}}", file.TargetPath);
|
||||
}
|
||||
}
|
||||
@@ -693,9 +696,13 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
/// Waits for all priority files to be processed before allowing non-priority files to be processed.
|
||||
/// If the current file is a priority file, it decrements the counter and signals completion when all priority files are done.
|
||||
/// If the current file is not a priority file, it waits until all priority files have been processed.
|
||||
/// When the last priority file is processed, <see cref="RestoreHandler.InvokeBulkRestoreStartAsync"/> is
|
||||
/// invoked to notify restore callback modules that the bulk restore is starting.
|
||||
/// </summary>
|
||||
/// <param name="file">The file request being processed.</param>
|
||||
private static async Task WaitForPriorityFilesIfNeededAsync(FileRequest file)
|
||||
/// <param name="modules">The loaded generic modules, used to dispatch the bulk-restore-start callback. May be null.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
private static async Task WaitForPriorityFilesIfNeededAsync(FileRequest file, IEnumerable<IGenericModule>? modules, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.IsPriorityFile)
|
||||
{
|
||||
@@ -710,7 +717,17 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
|
||||
if (shouldSignal)
|
||||
{
|
||||
priority_files_completed.SetResult();
|
||||
// All priority files have been restored and the bulk restore is starting.
|
||||
// The first processor to detect completion notifies the restore callback
|
||||
// modules before signaling the other processors to continue.
|
||||
try
|
||||
{
|
||||
await RestoreHandler.InvokeBulkRestoreStartAsync(modules, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
priority_files_completed.SetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Threading;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Main.Volumes;
|
||||
using Duplicati.Library.Utility;
|
||||
|
||||
@@ -177,7 +178,9 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
/// <param name="BlocksetID">The BlocksetID of the file.</param>
|
||||
/// <param name="IsPriorityFile">Whether this is a priority file that should be processed before other files.</param>
|
||||
/// <param name="IsAlternateDataStream">Whether this request is for an alternate data stream that must be restored after its host file/folder.</param>
|
||||
public class FileRequest(long ID, string OriginalPath, string TargetPath, string Hash, long Length, long BlocksetID, bool IsPriorityFile = false, bool IsAlternateDataStream = false)
|
||||
/// <param name="Version">The 0-based backup version index this file is being restored from (0 = newest). Defaults to 0.</param>
|
||||
/// <param name="BackupTimestamp">The timestamp of the backup version this file is being restored from, in UTC. Defaults to <see cref="DateTime.MinValue"/>.</param>
|
||||
public class FileRequest(long ID, string OriginalPath, string TargetPath, string Hash, long Length, long BlocksetID, bool IsPriorityFile = false, bool IsAlternateDataStream = false, long Version = 0, DateTime BackupTimestamp = default)
|
||||
{
|
||||
public long ID { get; } = ID;
|
||||
public string OriginalPath { get; } = OriginalPath;
|
||||
@@ -187,6 +190,27 @@ namespace Duplicati.Library.Main.Operation.Restore
|
||||
public long BlocksetID { get; } = BlocksetID;
|
||||
public bool IsPriorityFile { get; } = IsPriorityFile;
|
||||
public bool IsAlternateDataStream { get; } = IsAlternateDataStream;
|
||||
/// <summary>
|
||||
/// The 0-based backup version index this file is being restored from (0 = newest).
|
||||
/// Used to report the version to <see cref="IRestoreCallbackModule"/> modules.
|
||||
/// </summary>
|
||||
public long Version { get; } = Version;
|
||||
/// <summary>
|
||||
/// The timestamp of the backup version this file is being restored from, in UTC.
|
||||
/// Used to report the backup timestamp to <see cref="IRestoreCallbackModule"/> modules.
|
||||
/// </summary>
|
||||
public DateTime BackupTimestamp { get; } = BackupTimestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of this <see cref="FileRequest"/> with the <see cref="Version"/>
|
||||
/// and <see cref="BackupTimestamp"/> fields set to the supplied values, keeping all
|
||||
/// other fields unchanged.
|
||||
/// </summary>
|
||||
/// <param name="version">The 0-based backup version index to set.</param>
|
||||
/// <param name="backupTimestamp">The backup version timestamp (UTC) to set.</param>
|
||||
/// <returns>A copy of this request with the version and backup timestamp updated.</returns>
|
||||
public FileRequest WithVersion(long version, DateTime backupTimestamp)
|
||||
=> new FileRequest(ID, OriginalPath, TargetPath, Hash, Length, BlocksetID, IsPriorityFile, IsAlternateDataStream, version, backupTimestamp);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -228,10 +228,16 @@ namespace Duplicati.Library.Main.Operation
|
||||
m_options.RawOptions["version"] = string.Join(",", Enumerable.Range(0, versions.Length).Select(x => x.ToString()));
|
||||
}
|
||||
|
||||
// Resolve the backup version index (0 = newest) and timestamp (UTC) for the
|
||||
// version being restored, forwarding them to the restore callback modules so
|
||||
// the engine methods below do not need to look them up. When the caller already
|
||||
// knows the timestamp (the --restore-all-files path), it is used directly.
|
||||
var (restoreVersion, restoreBackupTimestamp) = await ResolveRestoreVersionAndTimestampAsync(db, versions, time, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (m_options.RestoreLegacy)
|
||||
await DoRunAsync(backendManager, db, filter, restoreDestination, cancellationToken).ConfigureAwait(false);
|
||||
await DoRunAsync(backendManager, db, filter, restoreDestination, restoreVersion, restoreBackupTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
else
|
||||
await DoRunNewAsync(backendManager, db, filter, restoreDestination, cancellationToken).ConfigureAwait(false);
|
||||
await DoRunNewAsync(backendManager, db, filter, restoreDestination, restoreVersion, restoreBackupTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -340,6 +346,9 @@ namespace Duplicati.Library.Main.Operation
|
||||
// provider's path validation continues to accept them.
|
||||
var versionDestination = new VersionedRestoreDestinationProvider(restoreDestination, versionTarget);
|
||||
|
||||
// Forward the already-known version index so the engine methods and restore
|
||||
// callback modules receive it without any lookup. The backup timestamp is
|
||||
// resolved from the fileset list inside RunSingleVersionRestoreAsync.
|
||||
await RunSingleVersionRestoreAsync(backendManager, filter, versionDestination, m_options.Time, [fileset.VersionIndex], cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
@@ -666,6 +675,140 @@ namespace Duplicati.Library.Main.Operation
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag used for logging restore-callback module invocations.
|
||||
/// </summary>
|
||||
private static readonly string RESTORE_CALLBACK_LOGTAG = Logging.Log.LogTagFromType<RestoreHandler>() + ".RestoreCallback";
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <see cref="IRestoreCallbackModule.OnFileRestoredAsync"/> on every loaded
|
||||
/// <see cref="IRestoreCallbackModule"/> module, passing the backup version index, the backup
|
||||
/// timestamp and the restored file's target path. Exceptions are logged and isolated.
|
||||
/// </summary>
|
||||
/// <param name="modules">The loaded generic modules, in their activation order.</param>
|
||||
/// <param name="version">The 0-based backup version index the file was restored from (0 = newest).</param>
|
||||
/// <param name="path">The target path of the restored file.</param>
|
||||
/// <param name="backupTimestamp">The timestamp of the backup version the file was restored from, in UTC.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous dispatch operation.</returns>
|
||||
internal static async Task InvokeFileRestoredAsync(IEnumerable<IGenericModule>? modules, long version, string path, DateTime backupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
if (modules == null)
|
||||
return;
|
||||
foreach (var mx in modules)
|
||||
{
|
||||
if (mx is not IRestoreCallbackModule module)
|
||||
continue;
|
||||
try { await module.OnFileRestoredAsync(version, path, backupTimestamp, cancellationToken).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logging.Log.WriteWarningMessage(RESTORE_CALLBACK_LOGTAG, $"OnFileRestoredError{mx.Key}", ex, "OnFileRestored callback {0} failed: {1}", mx.Key, ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <see cref="IRestoreCallbackModule.OnPreparePriorityFilesAsync"/> on every loaded
|
||||
/// <see cref="IRestoreCallbackModule"/> module, passing the priority-files list by reference
|
||||
/// so the modules may modify it, along with the backup version index and timestamp being
|
||||
/// restored. Exceptions thrown by a module are logged and isolated so a single misbehaving
|
||||
/// module cannot abort the restore.
|
||||
/// </summary>
|
||||
/// <param name="modules">The loaded generic modules, in their activation order.</param>
|
||||
/// <param name="priorityFiles">The priority-files list that modules may modify in place.</param>
|
||||
/// <param name="version">The 0-based backup version index being restored (0 = newest).</param>
|
||||
/// <param name="backupTimestamp">The timestamp of the backup version being restored, in UTC.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous dispatch operation.</returns>
|
||||
internal static async Task InvokePreparePriorityFilesAsync(IEnumerable<IGenericModule>? modules, IList<string> priorityFiles, long version, DateTime backupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
if (modules == null)
|
||||
return;
|
||||
foreach (var mx in modules)
|
||||
{
|
||||
if (mx is not IRestoreCallbackModule module)
|
||||
continue;
|
||||
try { await module.OnPreparePriorityFilesAsync(priorityFiles, version, backupTimestamp, cancellationToken).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logging.Log.WriteWarningMessage(RESTORE_CALLBACK_LOGTAG, $"OnPreparePriorityFilesError{mx.Key}", ex, "OnPreparePriorityFiles callback {0} failed: {1}", mx.Key, ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <see cref="IRestoreCallbackModule.OnBulkRestoreStartAsync"/> on every loaded
|
||||
/// <see cref="IRestoreCallbackModule"/> module. Exceptions are logged and isolated.
|
||||
/// </summary>
|
||||
/// <param name="modules">The loaded generic modules, in their activation order.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous dispatch operation.</returns>
|
||||
internal static async Task InvokeBulkRestoreStartAsync(IEnumerable<IGenericModule>? modules, CancellationToken cancellationToken)
|
||||
{
|
||||
if (modules == null)
|
||||
return;
|
||||
foreach (var mx in modules)
|
||||
{
|
||||
if (mx is not IRestoreCallbackModule module)
|
||||
continue;
|
||||
try { await module.OnBulkRestoreStartAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logging.Log.WriteWarningMessage(RESTORE_CALLBACK_LOGTAG, $"OnBulkRestoreStartError{mx.Key}", ex, "OnBulkRestoreStart callback {0} failed: {1}", mx.Key, ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the 0-based backup version index (0 = newest) and the backup timestamp
|
||||
/// (UTC) for the version being restored, so the value can be forwarded to the restore
|
||||
/// callback modules rather than reverse-looked-up inside the engine methods.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a forward resolution: it reads the fileset list (newest first) once and
|
||||
/// picks the entry matching the requested <paramref name="versions"/>/<paramref name="time"/>.
|
||||
/// </remarks>
|
||||
/// <param name="database">The restore database.</param>
|
||||
/// <param name="versions">The requested version indices (0 = newest), or null for a time-based restore.</param>
|
||||
/// <param name="time">The restore time, used when <paramref name="versions"/> is null/empty.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to monitor for cancellation requests.</param>
|
||||
/// <returns>A tuple with the 0-based backup version index and the backup timestamp (UTC).</returns>
|
||||
private static async Task<(long version, DateTime backupTimestamp)> ResolveRestoreVersionAndTimestampAsync(LocalRestoreDatabase database, long[]? versions, DateTime time, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Filesets ordered newest-first; the 0-based position is the version index.
|
||||
var filesets = await database.FilesetTimesAsync(cancellationToken)
|
||||
.ToArrayAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (filesets.Length == 0)
|
||||
return (0, DateTime.MinValue);
|
||||
|
||||
// Explicit version indices take precedence: the caller passes the absolute index.
|
||||
if (versions is { Length: > 0 })
|
||||
{
|
||||
var requestedIndex = versions[0];
|
||||
// On a recreated (no-local-db) database the list may contain fewer entries
|
||||
// than the absolute index; the single present entry is the requested version.
|
||||
var timestamp = requestedIndex >= 0 && requestedIndex < filesets.Length
|
||||
? filesets[requestedIndex].Value
|
||||
: filesets[0].Value;
|
||||
return (requestedIndex, timestamp.ToUniversalTime());
|
||||
}
|
||||
|
||||
// Time-based restore: pick the newest fileset at or before the requested time.
|
||||
if (time.Ticks > 0)
|
||||
{
|
||||
var compareTime = time.ToUniversalTime();
|
||||
for (int i = 0; i < filesets.Length; i++)
|
||||
{
|
||||
if (filesets[i].Value.ToUniversalTime() <= compareTime)
|
||||
return (i, filesets[i].Value.ToUniversalTime());
|
||||
}
|
||||
}
|
||||
|
||||
// Default: the newest backup (version 0).
|
||||
return (0, filesets[0].Value.ToUniversalTime());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.Log.WriteWarningMessage(LOGTAG, "ResolveRestoreVersionFailed", ex, "Failed to resolve restore version info: {0}", ex.Message);
|
||||
return (0, DateTime.MinValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform the restore operation.
|
||||
/// This is the new implementation, which utilizes a CSP network of processes to perform the restore.
|
||||
@@ -673,8 +816,10 @@ namespace Duplicati.Library.Main.Operation
|
||||
/// <param name="database">The database containing information about the restore.</param>
|
||||
/// <param name="filter">The filter of which files to restore.</param>
|
||||
/// <param name="restoreDestination">The destination to restore to.</param>
|
||||
/// <param name="restoreVersion">The forwarded 0-based backup version index being restored (0 = newest), reported to restore callback modules.</param>
|
||||
/// <param name="restoreBackupTimestamp">The forwarded backup timestamp (UTC) being restored, reported to restore callback modules.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to monitor for cancellation requests.</param>
|
||||
private async Task DoRunNewAsync(IBackendManager backendManager, LocalRestoreDatabase database, IFilter? filter, IRestoreDestinationProvider restoreDestination, CancellationToken cancellationToken)
|
||||
private async Task DoRunNewAsync(IBackendManager backendManager, LocalRestoreDatabase database, IFilter? filter, IRestoreDestinationProvider restoreDestination, long restoreVersion, DateTime restoreBackupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
// Perform initial setup
|
||||
await Utility.UpdateOptionsFromDbAsync(database, m_options, cancellationToken)
|
||||
@@ -724,14 +869,17 @@ namespace Duplicati.Library.Main.Operation
|
||||
Restore.DeadlockTimer.initial_threshold = (int)TimeSpan.FromMinutes(1).TotalMilliseconds * Math.Max(1, (int)(volsize / (10L * 1024L * 1024L)));
|
||||
Restore.FileProcessor.file_processors_restoring_files = m_options.RestoreFileProcessors;
|
||||
|
||||
// Initialize priority files synchronization
|
||||
var priorityFiles = restoreDestination.GetPriorityFiles();
|
||||
Restore.FileProcessor.priority_files_remaining = priorityFiles.Count;
|
||||
Restore.FileProcessor.priority_files_completed = new TaskCompletionSource();
|
||||
// Initialize priority files synchronization.
|
||||
// Wrap in a List<> so restore callback modules can freely modify the list
|
||||
// (the destination provider may return a fixed-size array).
|
||||
var priorityFiles = new List<string>(restoreDestination.GetPriorityFiles());
|
||||
// Allow restore callback modules to inspect and modify the priority-files list
|
||||
// before any files are restored, passing the forwarded version and backup timestamp.
|
||||
await InvokePreparePriorityFilesAsync(m_options.LoadedModules, priorityFiles, restoreVersion, restoreBackupTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create the process network
|
||||
var filelister = Restore.FileLister.RunAsync(channels, database, m_options, m_result, priorityFiles);
|
||||
var fileprocessors = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(i => Restore.FileProcessor.RunAsync(channels, database, fileprocessor_requests[i], fileprocessor_responses[i], restoreDestination, m_options, m_result)).ToArray();
|
||||
var filelister = Restore.FileLister.RunAsync(channels, database, m_options, m_result, priorityFiles, restoreVersion, restoreBackupTimestamp, m_options.LoadedModules);
|
||||
var fileprocessors = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(i => Restore.FileProcessor.RunAsync(channels, database, fileprocessor_requests[i], fileprocessor_responses[i], restoreDestination, m_options, m_result, m_options.LoadedModules)).ToArray();
|
||||
var blockmanager = Restore.BlockManager.RunAsync(channels, database, fileprocessor_requests, fileprocessor_responses, m_options, m_result);
|
||||
var volumecache = Restore.VolumeManager.RunAsync(channels, m_options, m_result);
|
||||
var volumedownloaders = Enumerable.Range(0, m_options.RestoreVolumeDownloaders).Select(i => Restore.VolumeDownloader.RunAsync(channels, database, backendManager, m_options, m_result)).ToArray();
|
||||
@@ -856,7 +1004,7 @@ namespace Duplicati.Library.Main.Operation
|
||||
/// <param name="filter">The filter of which files to restore.</param>
|
||||
/// <param name="restoreDestination">The destination to restore to.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to monitor for cancellation requests.</param>
|
||||
private async Task DoRunAsync(IBackendManager backendManager, LocalRestoreDatabase database, Library.Utility.IFilter? filter, IRestoreDestinationProvider restoreDestination, CancellationToken cancellationToken)
|
||||
private async Task DoRunAsync(IBackendManager backendManager, LocalRestoreDatabase database, Library.Utility.IFilter? filter, IRestoreDestinationProvider restoreDestination, long restoreVersion, DateTime restoreBackupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var metadatastorage = new RestoreHandlerMetadataStorage())
|
||||
{
|
||||
@@ -873,8 +1021,13 @@ namespace Duplicati.Library.Main.Operation
|
||||
await FilelistProcessor.VerifyRemoteListAsync(backendManager, m_options, database, m_result.BackendWriter, latestVolumesOnly: false, verifyMode: FilelistProcessor.VerifyMode.VerifyOnly, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Get priority files from restore destination
|
||||
var priorityFiles = restoreDestination.GetPriorityFiles();
|
||||
// Get priority files from restore destination.
|
||||
// Wrap in a List<> so restore callback modules can freely modify the list
|
||||
// (the destination provider may return a fixed-size array).
|
||||
var priorityFiles = new List<string>(restoreDestination.GetPriorityFiles());
|
||||
// Allow restore callback modules to inspect and modify the priority-files list
|
||||
// before any files are restored, passing the forwarded version and backup timestamp.
|
||||
await InvokePreparePriorityFilesAsync(m_options.LoadedModules, priorityFiles, restoreVersion, restoreBackupTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (priorityFiles.Count > 0)
|
||||
{
|
||||
@@ -897,13 +1050,16 @@ namespace Duplicati.Library.Main.Operation
|
||||
|
||||
// Phase 2: Restore remaining files (excluding priority files)
|
||||
Logging.Log.WriteInformationMessage(LOGTAG, "RemainingFilesRestore", "Restoring remaining files (excluding priority files)");
|
||||
// All priority files have been restored and the bulk restore is starting.
|
||||
await InvokeBulkRestoreStartAsync(m_options.LoadedModules, cancellationToken).ConfigureAwait(false);
|
||||
var phase2Filter = BuildExcludePriorityFilter(priorityFiles, filter);
|
||||
await RestoreCoreAsync(backendManager, database, phase2Filter, restoreDestination, metadatastorage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No priority files — single phase
|
||||
// No priority files — single phase, the bulk restore starts immediately.
|
||||
await InvokeBulkRestoreStartAsync(m_options.LoadedModules, cancellationToken).ConfigureAwait(false);
|
||||
await RestoreCoreAsync(backendManager, database, filter, restoreDestination, metadatastorage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// Copyright (C) 2026, The Duplicati Team
|
||||
// https://duplicati.com, hello@duplicati.com
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.DynamicLoader;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Main;
|
||||
using Duplicati.Library.Main.Operation;
|
||||
using Duplicati.Library.Main.Operation.Restore;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="IRestoreCallbackModule"/> restore hook interface and its
|
||||
/// wiring in <see cref="RestoreHandler"/> / the restore FileProcessor.
|
||||
/// </summary>
|
||||
[NonParallelizable]
|
||||
public class RestoreCallbackModuleTests : BasicSetupHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// A minimal <see cref="IRestoreCallbackModule"/> implementation that records calls
|
||||
/// on the instance, used for the dispatch-helper unit tests.
|
||||
/// </summary>
|
||||
private sealed class FakeRestoreCallbackModule : IRestoreCallbackModule, IGenericPriorityModule
|
||||
{
|
||||
public string Key { get; set; } = "fake";
|
||||
public string DisplayName => "Fake";
|
||||
public string Description => "Fake restore callback module";
|
||||
public bool LoadAsDefault => false;
|
||||
public int Priority { get; set; }
|
||||
public IList<ICommandLineArgument> SupportedCommands => new List<ICommandLineArgument>();
|
||||
|
||||
public List<(string files, long version, DateTime timestamp)> PrepareCalls { get; } = new();
|
||||
public int BulkStartCalls { get; set; }
|
||||
public List<(long version, string path, DateTime timestamp)> FileRestoredCalls { get; } = new();
|
||||
|
||||
public Action<IList<string>>? OnPrepare { get; set; }
|
||||
public Action? OnBulk { get; set; }
|
||||
public Action<long, string>? OnFile { get; set; }
|
||||
|
||||
public Task OnPreparePriorityFilesAsync(IList<string> priorityFiles, long version, DateTime backupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareCalls.Add((string.Join(",", priorityFiles), version, backupTimestamp));
|
||||
OnPrepare?.Invoke(priorityFiles);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task OnBulkRestoreStartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
BulkStartCalls++;
|
||||
OnBulk?.Invoke();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task OnFileRestoredAsync(long version, string path, DateTime backupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
FileRestoredCalls.Add((version, path, backupTimestamp));
|
||||
OnFile?.Invoke(version, path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Configure(IDictionary<string, string> commandlineOptions) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A module that is NOT a restore callback module, used to verify the dispatch
|
||||
/// helpers skip non-matching modules.
|
||||
/// </summary>
|
||||
private sealed class NonCallbackModule : IGenericModule
|
||||
{
|
||||
public string Key => "non-callback";
|
||||
public string DisplayName => "NonCallback";
|
||||
public string Description => "";
|
||||
public bool LoadAsDefault => false;
|
||||
public IList<ICommandLineArgument> SupportedCommands => new List<ICommandLineArgument>();
|
||||
public void Configure(IDictionary<string, string> commandlineOptions) { }
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task InvokePreparePriorityFilesCallsModulesAndAllowsMutationAsync()
|
||||
{
|
||||
var modA = new FakeRestoreCallbackModule { Key = "a" };
|
||||
var modB = new FakeRestoreCallbackModule { Key = "b" };
|
||||
modA.OnPrepare = list => list.Add("from-a");
|
||||
modB.OnPrepare = list => list.Remove("remove-me");
|
||||
|
||||
var priorityFiles = new List<string> { "keep", "remove-me" };
|
||||
// Mix in a non-callback module to ensure it is skipped.
|
||||
const long expectedVersion = 7;
|
||||
var expectedTimestamp = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
await RestoreHandler.InvokePreparePriorityFilesAsync(new IGenericModule[] { new NonCallbackModule(), modA, modB }, priorityFiles, expectedVersion, expectedTimestamp, CancellationToken.None);
|
||||
|
||||
// Both callback modules were invoked exactly once, in order.
|
||||
Assert.AreEqual(1, modA.PrepareCalls.Count);
|
||||
Assert.AreEqual(1, modB.PrepareCalls.Count);
|
||||
// The version and backup timestamp were forwarded to the modules.
|
||||
Assert.AreEqual(expectedVersion, modA.PrepareCalls[0].version);
|
||||
Assert.AreEqual(expectedTimestamp, modA.PrepareCalls[0].timestamp);
|
||||
Assert.AreEqual(expectedVersion, modB.PrepareCalls[0].version);
|
||||
Assert.AreEqual(expectedTimestamp, modB.PrepareCalls[0].timestamp);
|
||||
// modA saw the original list, modB saw the list after modA mutated it.
|
||||
Assert.AreEqual("keep,remove-me", modA.PrepareCalls[0].files);
|
||||
Assert.AreEqual("keep,remove-me,from-a", modB.PrepareCalls[0].files);
|
||||
|
||||
// The shared list reflects both mutations.
|
||||
Assert.That(priorityFiles, Is.EqualTo(new[] { "keep", "from-a" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task InvokePreparePriorityFilesIsolatedFromThrowingModulesAsync()
|
||||
{
|
||||
var throwing = new FakeRestoreCallbackModule { Key = "throw", OnPrepare = _ => throw new InvalidOperationException("boom") };
|
||||
var after = new FakeRestoreCallbackModule { Key = "after" };
|
||||
|
||||
var priorityFiles = new List<string> { "a" };
|
||||
await RestoreHandler.InvokePreparePriorityFilesAsync(new IGenericModule[] { throwing, after }, priorityFiles, 0, DateTime.MinValue, CancellationToken.None);
|
||||
|
||||
// The throwing module did not abort dispatch to the next module.
|
||||
Assert.AreEqual(1, after.PrepareCalls.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task InvokeBulkRestoreStartCallsAllModulesInOrderAsync()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var modA = new FakeRestoreCallbackModule { Key = "a", OnBulk = () => order.Add("a") };
|
||||
var modB = new FakeRestoreCallbackModule { Key = "b", OnBulk = () => order.Add("b") };
|
||||
|
||||
await RestoreHandler.InvokeBulkRestoreStartAsync(new IGenericModule[] { new NonCallbackModule(), modA, modB }, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(1, modA.BulkStartCalls);
|
||||
Assert.AreEqual(1, modB.BulkStartCalls);
|
||||
Assert.That(order, Is.EqualTo(new[] { "a", "b" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task InvokeFileRestoredPassesVersionAndPathAsync()
|
||||
{
|
||||
var mod = new FakeRestoreCallbackModule();
|
||||
const long expectedVersion = 2;
|
||||
var expectedTimestamp = new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
await RestoreHandler.InvokeFileRestoredAsync(new IGenericModule[] { new NonCallbackModule(), mod }, expectedVersion, "/some/path/file.txt", expectedTimestamp, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(1, mod.FileRestoredCalls.Count);
|
||||
Assert.AreEqual(expectedVersion, mod.FileRestoredCalls[0].version);
|
||||
Assert.AreEqual("/some/path/file.txt", mod.FileRestoredCalls[0].path);
|
||||
Assert.AreEqual(expectedTimestamp, mod.FileRestoredCalls[0].timestamp);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task InvokeFileRestoredIsolatedFromThrowingModulesAsync()
|
||||
{
|
||||
var throwing = new FakeRestoreCallbackModule { Key = "throw", OnFile = (_, _) => throw new InvalidOperationException("boom") };
|
||||
var after = new FakeRestoreCallbackModule { Key = "after" };
|
||||
|
||||
await RestoreHandler.InvokeFileRestoredAsync(new IGenericModule[] { throwing, after }, 0, "/p", DateTime.MinValue, CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(1, after.FileRestoredCalls.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task DispatchHelpersAcceptNullModulesAsync()
|
||||
{
|
||||
// Passing null must not throw.
|
||||
await RestoreHandler.InvokePreparePriorityFilesAsync(null, new List<string>(), 0, DateTime.MinValue, CancellationToken.None);
|
||||
await RestoreHandler.InvokeBulkRestoreStartAsync(null, CancellationToken.None);
|
||||
await RestoreHandler.InvokeFileRestoredAsync(null, 0, "/p", DateTime.MinValue, CancellationToken.None);
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------
|
||||
// Integration tests: register a real module in the GenericLoader and run a restore.
|
||||
// ----------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A restore-callback module that records invocations into static state. Static
|
||||
/// state is required because <see cref="GenericLoader.GetModule(string)"/> creates a
|
||||
/// fresh instance per operation, so the instance that runs during the restore is not
|
||||
/// the same instance the test registered.
|
||||
/// </summary>
|
||||
public class RecordingRestoreCallbackModule : IRestoreCallbackModule
|
||||
{
|
||||
public const string KEY = "test-restore-callback";
|
||||
|
||||
public static readonly object RecordLock = new();
|
||||
public static List<(string files, long version, DateTime timestamp)> PrepareCalls { get; } = new();
|
||||
public static int BulkStartCalls;
|
||||
public static List<(long version, string path, DateTime timestamp)> FileRestoredCalls { get; } = new();
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
lock (RecordLock)
|
||||
{
|
||||
PrepareCalls.Clear();
|
||||
BulkStartCalls = 0;
|
||||
FileRestoredCalls.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public string Key => KEY;
|
||||
public string DisplayName => "Test Restore Callback";
|
||||
public string Description => "Records restore callback invocations for tests";
|
||||
public bool LoadAsDefault => false;
|
||||
public IList<ICommandLineArgument> SupportedCommands => new List<ICommandLineArgument>();
|
||||
|
||||
public void Configure(IDictionary<string, string> commandlineOptions) { }
|
||||
|
||||
public Task OnPreparePriorityFilesAsync(IList<string> priorityFiles, long version, DateTime backupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (RecordLock)
|
||||
PrepareCalls.Add((string.Join("|", priorityFiles), version, backupTimestamp));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task OnBulkRestoreStartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (RecordLock)
|
||||
BulkStartCalls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task OnFileRestoredAsync(long version, string path, DateTime backupTimestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (RecordLock)
|
||||
FileRestoredCalls.Add((version, path, backupTimestamp));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="RecordingRestoreCallbackModule"/> into the static
|
||||
/// <see cref="GenericLoader"/> so it is discovered and activated during a restore
|
||||
/// operation (when enabled via <c>--enable-module</c>). Uses reflection because the
|
||||
/// loader's module table is not publicly writable.
|
||||
/// </summary>
|
||||
private static void RegisterRecordingModule()
|
||||
{
|
||||
var loaderType = typeof(GenericLoader);
|
||||
var loaderField = loaderType.GetField("_loader", BindingFlags.NonPublic | BindingFlags.Static)
|
||||
?? throw new InvalidOperationException("Could not find GenericLoader._loader");
|
||||
var lazy = loaderField.GetValue(null);
|
||||
var loaderSub = lazy!.GetType().GetProperty("Value")!.GetValue(lazy);
|
||||
var addModule = loaderSub!.GetType().GetMethod("AddModule", BindingFlags.Public | BindingFlags.Instance)
|
||||
?? throw new InvalidOperationException("Could not find AddModule on loader");
|
||||
addModule.Invoke(loaderSub, new object[] { new RecordingRestoreCallbackModule() });
|
||||
}
|
||||
|
||||
[OneTimeSetUp]
|
||||
public void OneTimeSetup()
|
||||
{
|
||||
RegisterRecordingModule();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task RestoreCallbacksFireOnNewRestoreAsync()
|
||||
{
|
||||
RecordingRestoreCallbackModule.Reset();
|
||||
|
||||
// Write two files, one of which we mark as a priority file via the restore
|
||||
// destination provider (priority files are derived from the target folder).
|
||||
var file1 = Path.Combine(this.DATAFOLDER, "file1.txt");
|
||||
var file2 = Path.Combine(this.DATAFOLDER, "file2.txt");
|
||||
File.WriteAllText(file1, "content-1");
|
||||
File.WriteAllText(file2, "content-2");
|
||||
|
||||
var backupOptions = new Dictionary<string, string>(this.TestOptions);
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
|
||||
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
|
||||
|
||||
var restoreOptions = new Dictionary<string, string>(this.TestOptions)
|
||||
{
|
||||
["restore-path"] = this.RESTOREFOLDER,
|
||||
["restore-legacy"] = "false",
|
||||
["enable-module"] = RecordingRestoreCallbackModule.KEY,
|
||||
};
|
||||
|
||||
var restoreStartedAt = DateTime.UtcNow;
|
||||
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
|
||||
TestUtils.AssertResults(await c.RestoreAsync(new[] { "*" }));
|
||||
|
||||
// OnPreparePriorityFilesAsync is always called (even with an empty list).
|
||||
Assert.That(RecordingRestoreCallbackModule.PrepareCalls.Count, Is.GreaterThanOrEqualTo(1), "OnPreparePriorityFilesAsync should be invoked");
|
||||
// The reported version is 0 (newest backup) for the single restored version.
|
||||
Assert.AreEqual(0, RecordingRestoreCallbackModule.PrepareCalls[0].version, "Prepare should report version 0");
|
||||
// The reported backup timestamp is the newest backup's timestamp, created just before this restore.
|
||||
Assert.That(RecordingRestoreCallbackModule.PrepareCalls[0].timestamp, Is.GreaterThan(restoreStartedAt.AddMinutes(-5)).And.LessThan(DateTime.UtcNow.AddMinutes(1)),
|
||||
"Prepare should report a recent backup timestamp");
|
||||
// OnBulkRestoreStartAsync is called exactly once when the bulk restore starts.
|
||||
Assert.AreEqual(1, RecordingRestoreCallbackModule.BulkStartCalls, "OnBulkRestoreStartAsync should be invoked once");
|
||||
// OnFileRestoredAsync is called for each restored file on the new engine.
|
||||
Assert.That(RecordingRestoreCallbackModule.FileRestoredCalls.Count, Is.GreaterThanOrEqualTo(2), "OnFileRestoredAsync should be invoked for each restored file");
|
||||
// The reported version is 0 (newest backup) for the single restored version.
|
||||
Assert.IsTrue(RecordingRestoreCallbackModule.FileRestoredCalls.All(x => x.version == 0), "Restored files should report version 0");
|
||||
// The reported backup timestamp is recent (the backup was created just before this restore).
|
||||
Assert.IsTrue(RecordingRestoreCallbackModule.FileRestoredCalls.All(x => x.timestamp > restoreStartedAt.AddMinutes(-5) && x.timestamp < DateTime.UtcNow.AddMinutes(1)),
|
||||
"Restored files should report a recent backup timestamp");
|
||||
// The reported paths are within the restore folder.
|
||||
Assert.IsTrue(RecordingRestoreCallbackModule.FileRestoredCalls.All(x => x.path.StartsWith(this.RESTOREFOLDER, StringComparison.OrdinalIgnoreCase)),
|
||||
"Restored file paths should be under the restore folder");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task OnFileRestoredNotCalledOnLegacyRestoreAsync()
|
||||
{
|
||||
RecordingRestoreCallbackModule.Reset();
|
||||
|
||||
var file1 = Path.Combine(this.DATAFOLDER, "legacy-file.txt");
|
||||
File.WriteAllText(file1, "legacy-content");
|
||||
|
||||
var backupOptions = new Dictionary<string, string>(this.TestOptions);
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
|
||||
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
|
||||
|
||||
var restoreOptions = new Dictionary<string, string>(this.TestOptions)
|
||||
{
|
||||
["restore-path"] = this.RESTOREFOLDER,
|
||||
["restore-legacy"] = "true",
|
||||
["enable-module"] = RecordingRestoreCallbackModule.KEY,
|
||||
};
|
||||
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
|
||||
TestUtils.AssertResults(await c.RestoreAsync(new[] { "*" }));
|
||||
|
||||
// OnPreparePriorityFilesAsync and OnBulkRestoreStartAsync are invoked on the legacy engine too.
|
||||
Assert.That(RecordingRestoreCallbackModule.PrepareCalls.Count, Is.GreaterThanOrEqualTo(1), "OnPreparePriorityFilesAsync should be invoked on legacy restore");
|
||||
Assert.AreEqual(1, RecordingRestoreCallbackModule.BulkStartCalls, "OnBulkRestoreStartAsync should be invoked once on legacy restore");
|
||||
// OnFileRestoredAsync must NOT be called when --restore-legacy is set.
|
||||
Assert.AreEqual(0, RecordingRestoreCallbackModule.FileRestoredCalls.Count, "OnFileRestoredAsync must not be called on legacy restore");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RestoreHandler")]
|
||||
public async Task RestoreCallbackReceivesEmptyPriorityListWhenNoPriorityFilesAsync()
|
||||
{
|
||||
RecordingRestoreCallbackModule.Reset();
|
||||
|
||||
var file1 = Path.Combine(this.DATAFOLDER, "nopriority.txt");
|
||||
File.WriteAllText(file1, "no-priority-content");
|
||||
|
||||
var backupOptions = new Dictionary<string, string>(this.TestOptions);
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
|
||||
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
|
||||
|
||||
var restoreOptions = new Dictionary<string, string>(this.TestOptions)
|
||||
{
|
||||
["restore-path"] = this.RESTOREFOLDER,
|
||||
["restore-legacy"] = "false",
|
||||
["enable-module"] = RecordingRestoreCallbackModule.KEY,
|
||||
};
|
||||
|
||||
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
|
||||
TestUtils.AssertResults(await c.RestoreAsync(new[] { "*" }));
|
||||
|
||||
// With no priority files, OnPreparePriorityFilesAsync is still called once with an empty list,
|
||||
// and OnBulkRestoreStartAsync is called exactly once (the bulk restore starts immediately).
|
||||
Assert.AreEqual(1, RecordingRestoreCallbackModule.PrepareCalls.Count, "OnPreparePriorityFilesAsync should be invoked exactly once");
|
||||
Assert.AreEqual(string.Empty, RecordingRestoreCallbackModule.PrepareCalls[0].files, "Priority list should be empty");
|
||||
Assert.AreEqual(1, RecordingRestoreCallbackModule.BulkStartCalls, "OnBulkRestoreStartAsync should be invoked once");
|
||||
Assert.That(RecordingRestoreCallbackModule.FileRestoredCalls.Count, Is.GreaterThanOrEqualTo(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user