This PR has a large blast radius because it takes the final step and bumps up the Controller to be fully async. We have historically done a piece-by-piece update, so all operations were already async but the controller interface was kept synchronous. With this update, the controller is now fully async and all tests are updated. Most places where the new C# compiler warns about function names not ending in `Async` were also adressed, giving a massive refactor change. Functionally, no changes are done.
494 lines
23 KiB
C#
494 lines
23 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Duplicati.Library.Backend;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Utility;
|
|
|
|
#nullable enable
|
|
|
|
namespace Duplicati.Library.Main.Backend
|
|
{
|
|
/// <summary>
|
|
/// A lightweight backend manager that handles remote synchronization operations.
|
|
/// This class is designed to manage backend operations such as Get, Put, Delete, Rename, and List asynchronously.
|
|
/// It supports retrying operations with a specified delay and can automatically create folders if they do not exist.
|
|
/// The class implements IDisposable to ensure proper resource management.
|
|
/// It uses a streaming backend for efficient file operations and handles exceptions gracefully to allow for retries and recovery.
|
|
/// A retry incurs a new instantiation of the backend.
|
|
/// It is designed to mimic the behavior of Duplicati.Library.Main.Backend.BackendManager.
|
|
/// </summary>
|
|
/// <param name="backendUrl">The backend URL string.</param>
|
|
/// <param name="options">A dictionary of options to pass to the backend.</param>
|
|
/// <param name="maxRetries">The maximum number of retries for failed operations.</param>
|
|
/// <param name="retryDelay">The delay between retries, in milliseconds.</param>
|
|
/// <param name="autoCreateFolders">Whether to automatically create folders if they do not exist.</param>
|
|
/// <param name="retryWithExponentialBackoff">Whether to use exponential backoff for retries.</param>
|
|
/// <param name="progressUpdater">An optional progress updater for file operations.</param>
|
|
/// <param name="backendProgressUpdater">An optional progress updater for backend operations.</param>
|
|
public class LightWeightBackendManager : IDisposable
|
|
{
|
|
private static readonly string LOGTAG = Logging.Log.LogTagFromType<LightWeightBackendManager>();
|
|
|
|
public IBackend? _backend = null;
|
|
|
|
private bool _anyDownloaded = false;
|
|
private bool _anyUploaded = false;
|
|
private readonly bool _autoCreateFolders;
|
|
private readonly string _backendUrl;
|
|
private readonly int _maxRetries;
|
|
private readonly Dictionary<string, string?> _options;
|
|
private readonly int _retryDelay;
|
|
private readonly bool _retryWithExponentialBackoff;
|
|
private int _currentRetryDelay;
|
|
private IStreamingBackend? _streamingBackend = null;
|
|
private readonly IOperationProgressUpdater? _progressUpdater;
|
|
private readonly IBackendProgressUpdater? _backendProgressUpdater;
|
|
|
|
public LightWeightBackendManager(string backendUrl, Dictionary<string, string?> options, int maxRetries = 3, int retryDelay = 1000, bool autoCreateFolders = false, bool retryWithExponentialBackoff = false)
|
|
: this(backendUrl, options, maxRetries, retryDelay, autoCreateFolders, retryWithExponentialBackoff, null, null)
|
|
{ }
|
|
|
|
internal LightWeightBackendManager(string backendUrl, Dictionary<string, string?> options, int maxRetries = 3, int retryDelay = 1000, bool autoCreateFolders = false, bool retryWithExponentialBackoff = false, IOperationProgressUpdater? progressUpdater = null, IBackendProgressUpdater? backendProgressUpdater = null)
|
|
{
|
|
_backendUrl = backendUrl;
|
|
_maxRetries = maxRetries;
|
|
_options = options;
|
|
_retryDelay = retryDelay;
|
|
_currentRetryDelay = retryDelay;
|
|
_autoCreateFolders = autoCreateFolders;
|
|
_retryWithExponentialBackoff = retryWithExponentialBackoff;
|
|
_progressUpdater = progressUpdater;
|
|
_backendProgressUpdater = backendProgressUpdater;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a file from the remote backend.
|
|
/// </summary>
|
|
/// <param name="remotename">The name of the remote file to delete.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous delete operation.</returns>
|
|
public Task DeleteAsync(string remotename, CancellationToken token)
|
|
{
|
|
return RetryWithDelayAsync(
|
|
$"Delete {remotename}",
|
|
() => _streamingBackend!.DeleteAsync(remotename, token),
|
|
null,
|
|
false,
|
|
token
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the display name of the backend.
|
|
/// This property initializes the backend if it has not been instantiated yet.
|
|
/// </summary>
|
|
/// <returns>The display name of the backend.</returns>
|
|
public string DisplayName
|
|
{
|
|
get
|
|
{
|
|
Instantiate();
|
|
|
|
return _streamingBackend!.DisplayName;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes of the backend and streaming backend resources.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
_streamingBackend?.Dispose();
|
|
_streamingBackend = null;
|
|
_backend?.Dispose();
|
|
_backend = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", ex, "Error during Dispose", null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a file from the remote backend and writes it to the specified stream.
|
|
/// This method retries the operation with a delay if it fails.
|
|
/// </summary>
|
|
/// <param name="remotename">The name of the remote file to get.</param>
|
|
/// <param name="stream">The stream to write the file to.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous get operation.</returns>
|
|
public Task GetAsync(string remotename, Stream stream, CancellationToken token)
|
|
{
|
|
return RetryWithDelayAsync(
|
|
$"Get {remotename}",
|
|
async () =>
|
|
{
|
|
await _streamingBackend!.GetAsync(remotename, stream, token).ConfigureAwait(false);
|
|
_anyDownloaded = true;
|
|
},
|
|
stream,
|
|
true,
|
|
token
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a file from the remote backend and saves it to a temporary file.
|
|
/// This method retries the operation with a delay if it fails.
|
|
/// </summary>
|
|
/// <param name="remotename">The name of the remote file to get.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous get operation. The task result is the path to the temporary file containing the downloaded data.</returns>
|
|
public async Task<TempFile> GetAsync(string remotename, CancellationToken token)
|
|
{
|
|
var temp = new TempFile();
|
|
|
|
try
|
|
{
|
|
using var fileStream = System.IO.File.OpenWrite(temp);
|
|
using var progressStream = new ProgressReportingStream(fileStream, pg =>
|
|
{
|
|
_backendProgressUpdater?.UpdateProgress(remotename, pg);
|
|
_progressUpdater?.UpdateFileProgress(pg);
|
|
});
|
|
|
|
await GetAsync(remotename, progressStream, token)
|
|
.ConfigureAwait(false);
|
|
|
|
return temp;
|
|
}
|
|
catch
|
|
{
|
|
temp.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quota information from the backend if it supports quota operations.
|
|
/// </summary>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous operation. The task result is the quota information, or null if the backend does not support quota operations or an error occurs.</returns>
|
|
public async Task<IQuotaInfo?> GetQuotaInfoAsync(CancellationToken token)
|
|
{
|
|
if (_backend is IQuotaEnabledBackend qeb)
|
|
{
|
|
try
|
|
{
|
|
return await qeb.GetQuotaInfoAsync(token).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", ex, "Error getting quota info", null);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Instantiates the backend if it has not been instantiated yet.
|
|
/// If the backend is already instantiated, it simply returns.
|
|
/// If the maximum number of retries has been reached, it throws an InvalidOperationException.
|
|
/// This method is called internally to ensure that the backend is ready for operations.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">If the maximum number of instantiations has been reached.</exception>
|
|
private void Instantiate()
|
|
{
|
|
if (_backend != null)
|
|
{
|
|
// If we already have a backend, we can just return.
|
|
return;
|
|
}
|
|
|
|
_backend = DynamicLoader.BackendLoader.GetBackend(_backendUrl, _options)
|
|
?? throw new InvalidOperationException("Failed to instantiate backend.");
|
|
_streamingBackend = _backend as IStreamingBackend;
|
|
if (_streamingBackend == null || !_streamingBackend.SupportsStreaming)
|
|
{
|
|
_backend.Dispose();
|
|
_backend = null;
|
|
throw new InvalidOperationException("Backend does not support streaming operations.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists the files in the remote backend asynchronously.
|
|
/// </summary>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A list of the file entries on the remote backend.</returns>
|
|
public async Task<List<IFileEntry>> ListAsync(CancellationToken token)
|
|
{
|
|
// TODO It would be more graceful if this method returned an
|
|
// IAsyncEnumerable instead, capturing failures along the way,
|
|
// Followed by retrying / resuming the listing from where it
|
|
// crashed. Current "workaround" is to build the entire list before
|
|
// returning it.
|
|
List<IFileEntry> entries = [];
|
|
await RetryWithDelayAsync("List", async () =>
|
|
{
|
|
entries = await _streamingBackend!.ListAsync(token).ToListAsync().ConfigureAwait(false);
|
|
},
|
|
null,
|
|
false,
|
|
token)
|
|
.ConfigureAwait(false);
|
|
|
|
return entries;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts a file to the remote backend from the specified stream.
|
|
/// </summary>
|
|
/// <param name="remotename">The name of the remote file to put.</param>
|
|
/// <param name="stream">The stream containing the file data to put.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <param name="md5">Optional precomputed MD5 hash of the file.</param>
|
|
/// <param name="sha256">Optional precomputed SHA256 hash of the file.</param>
|
|
/// <param name="length">The length of the stream data. Required if hashes are provided.</param>
|
|
/// <returns>A task representing the asynchronous put operation.</returns>
|
|
public Task PutAsync(string remotename, Stream stream, CancellationToken token, string? md5 = null, string? sha256 = null, long? length = null)
|
|
{
|
|
return RetryWithDelayAsync(
|
|
$"Put {remotename}",
|
|
async () =>
|
|
{
|
|
var hashes_length_not_null = md5 is not null && sha256 is not null && length is not null;
|
|
if (hashes_length_not_null && _streamingBackend is Duplicati.Library.Backend.S3 s3backend)
|
|
await s3backend.PutWithHashAsync(remotename, stream, md5!, sha256!, length!.Value, token).ConfigureAwait(false);
|
|
else
|
|
await _streamingBackend!.PutAsync(remotename, stream, token).ConfigureAwait(false);
|
|
|
|
_anyUploaded = true;
|
|
},
|
|
stream,
|
|
false,
|
|
token
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts a file to the remote backend from a temporary file.
|
|
/// This method retries the operation with a delay if it fails.
|
|
/// </summary>
|
|
/// <param name="remotename">The name of the remote file to put.</param>
|
|
/// <param name="temp">The temporary file containing the data to upload.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous put operation.</returns>
|
|
public async Task PutAsync(string remotename, TempFile temp, CancellationToken token)
|
|
{
|
|
using var fileStream = System.IO.File.OpenRead(temp);
|
|
using var progressStream = new ProgressReportingStream(fileStream, pg =>
|
|
{
|
|
_backendProgressUpdater?.UpdateProgress(remotename, pg);
|
|
_progressUpdater?.UpdateFileProgress(pg);
|
|
});
|
|
|
|
await PutAsync(remotename, progressStream, token)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renames a file in the remote backend.
|
|
/// If the backend supports renaming, it uses the RenameAsync method.
|
|
/// If the backend does not support renaming, it downloads the file, renames it, and deletes the old one.
|
|
/// </summary>
|
|
/// <param name="oldname">The current name of the remote file.</param>
|
|
/// <param name="newname">The new name for the remote file.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous rename operation.</returns>
|
|
public Task RenameAsync(string oldname, string newname, CancellationToken token)
|
|
{
|
|
Instantiate();
|
|
|
|
return _backend switch
|
|
{
|
|
IStreamingBackend sb =>
|
|
RetryWithDelayAsync(
|
|
$"Rename {oldname} to {newname}",
|
|
async () =>
|
|
{
|
|
// Download the file, rename it, and delete the old one
|
|
using var downloaded = new MemoryStream();
|
|
await sb.GetAsync(oldname, downloaded, token).ConfigureAwait(false);
|
|
downloaded.Seek(0, SeekOrigin.Begin);
|
|
await sb.PutAsync(newname, downloaded, token).ConfigureAwait(false);
|
|
await sb.DeleteAsync(oldname, token).ConfigureAwait(false);
|
|
_anyUploaded = true;
|
|
_anyDownloaded = true;
|
|
},
|
|
null,
|
|
false,
|
|
token
|
|
),
|
|
IRenameEnabledBackend ireb =>
|
|
RetryWithDelayAsync(
|
|
$"Rename {oldname} to {newname}",
|
|
async () =>
|
|
{
|
|
await ireb.RenameAsync(oldname, newname, token).ConfigureAwait(false);
|
|
_anyUploaded = true;
|
|
_anyDownloaded = true;
|
|
},
|
|
null,
|
|
false,
|
|
token
|
|
),
|
|
_ => throw new InvalidOperationException("Backend does not support renaming."),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets the state of the backend manager.
|
|
/// This method resets the download and upload flags and resets the retry delay to its initial value.
|
|
/// </summary>
|
|
/// <returns>A task representing the asynchronous reset operation.</returns>
|
|
public async Task ResetAsync()
|
|
{
|
|
_anyDownloaded = false;
|
|
_anyUploaded = false;
|
|
_currentRetryDelay = _retryDelay;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retries an operation with a delay if it fails.
|
|
/// This method will instantiate the backend if it has not been instantiated yet.
|
|
/// If the operation fails, it will log the error, dispose of the current backend and streaming backend,
|
|
/// reset the stream if specified, and attempt to recover from the exception.
|
|
/// If recovery is not possible, it will wait for the specified retry delay before retrying the operation.
|
|
/// The retry delay can be increased exponentially if specified.
|
|
/// </summary>
|
|
/// <param name="operationName">The name of the operation being retried, used for logging.</param>
|
|
/// <param name="action">The action to perform.</param>
|
|
/// <param name="stream">The stream to use for the operation. Used when resetting the stream during recovery.</param>
|
|
/// <param name="resetStream">Whether to reset the stream if the operation fails.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
private async Task RetryWithDelayAsync(string operationName, Func<Task> action, Stream? stream, bool resetStream, CancellationToken token)
|
|
{
|
|
int instantiations = 0;
|
|
_currentRetryDelay = _retryDelay; // Reset the current retry delay to the initial value
|
|
|
|
do
|
|
{
|
|
// This will throw an exception if we've reached the max
|
|
// number of retries.
|
|
Instantiate();
|
|
instantiations++;
|
|
|
|
try
|
|
{
|
|
await action().ConfigureAwait(false);
|
|
return; // Exit the loop if the action succeeds.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "rsync", ex, "Error during operation: {0}", operationName);
|
|
Dispose(); // Dispose current backend and streaming backend.
|
|
|
|
// Reset the stream, as it's in a potentially faulty state.
|
|
if (stream != null && stream.CanSeek)
|
|
{
|
|
stream.Seek(0, SeekOrigin.Begin);
|
|
if (resetStream)
|
|
stream.SetLength(0);
|
|
}
|
|
|
|
// Try to see if we can recover from the error.
|
|
await TryRecoverFromExceptionAsync(ex, token).ConfigureAwait(false);
|
|
}
|
|
} while (instantiations < _maxRetries);
|
|
|
|
// If we reach here, it means all retries failed.
|
|
throw new InvalidOperationException($"Operation '{operationName}' failed after {instantiations} attempts.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to create a folder in the remote backend.
|
|
/// </summary>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation. The task result is true if the folder was created successfully, false otherwise.</returns>
|
|
public async Task<bool> TryCreateFolderAsync(CancellationToken token)
|
|
{
|
|
bool created = false;
|
|
await RetryWithDelayAsync("CreateFolder", async () =>
|
|
{
|
|
try
|
|
{
|
|
await _backend!.CreateFolderAsync(token).ConfigureAwait(false);
|
|
created = true; // Folder creation succeeded
|
|
}
|
|
catch
|
|
{
|
|
created = false; // Folder creation failed
|
|
}
|
|
},
|
|
null, false, token);
|
|
|
|
return created;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to recover from an exception that occurred during a backend operation.
|
|
/// This method checks for specific types of exceptions, such as DNS resolution failures or folder missing exceptions.
|
|
/// If a DNS failure is detected, it attempts to refresh the DNS name by re-instantiating the backend and resolving DNS names.
|
|
/// If the exception is a folder missing exception and auto-creation of folders is enabled, it attempts to create the folder.
|
|
/// If recovery is not possible, it waits for the specified retry delay.
|
|
/// The retry delay will be doubled if exponential backoff is enabled.
|
|
/// </summary>
|
|
/// <param name="ex">The exception that occurred during the operation.</param>
|
|
/// <param name="token">A cancellation token to cancel the operation.</param>
|
|
/// <returns>A task representing the asynchronous recovery operation.</returns>
|
|
private async Task TryRecoverFromExceptionAsync(Exception ex, CancellationToken token)
|
|
{
|
|
// Copied from Duplicati.Library.Main.Backend.BackendManager.Handler.
|
|
|
|
// Refresh DNS name if we fail to connect in order to prevent issues with incorrect DNS entries.
|
|
var dnsFailure = ExceptionExtensions.FlattenException(ex)
|
|
.Any(x =>
|
|
(x is System.Net.WebException wex && wex.Status == System.Net.WebExceptionStatus.NameResolutionFailure)
|
|
||
|
|
(x is System.Net.Sockets.SocketException sockEx && sockEx.SocketErrorCode == System.Net.Sockets.SocketError.HostNotFound)
|
|
);
|
|
|
|
if (dnsFailure)
|
|
{
|
|
try
|
|
{
|
|
Instantiate();
|
|
|
|
foreach (var name in await _backend!.GetDNSNamesAsync(token).ConfigureAwait(false) ?? [])
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
await System.Net.Dns.GetHostEntryAsync(name);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
var recovered = false;
|
|
|
|
// Check if this was a folder missing exception and we are allowed to autocreate folders
|
|
if (!(_anyDownloaded || _anyUploaded) && _autoCreateFolders && ExceptionExtensions.FlattenException(ex).Any(x => x is FolderMissingException))
|
|
{
|
|
if (await TryCreateFolderAsync(token).ConfigureAwait(false))
|
|
recovered = true;
|
|
}
|
|
|
|
// Finally, if we did not recover, wait the specified delay before retrying.
|
|
if (!recovered && _retryDelay > 0)
|
|
{
|
|
await Task.Delay(_currentRetryDelay, token).ConfigureAwait(false);
|
|
|
|
if (_retryWithExponentialBackoff)
|
|
_currentRetryDelay <<= 1; // Double the delay for exponential backoff
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|