#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using CoCoL; using Duplicati.Library.Interface; namespace Duplicati.Library.Main.Backend; partial class BackendManager { /// /// Wrapper class for making a backend disposable and reclaimable /// private sealed class ReclaimableBackend : IDisposable { /// /// The tag used for logging /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The backend being wrapped /// public IBackend Backend { get; } /// /// The pool where the backend should be returned to /// private readonly ConcurrentQueue pool; /// /// Whether the backend should be reused /// private bool reuse; /// /// Whether the backend wrapper has been disposed /// private bool disposed; /// /// Creates a new instance of the class /// /// The backend to wrap /// The pool where the backend should be returned to /// Whether the backend should be reused or disposed public ReclaimableBackend(IBackend backend, ConcurrentQueue pool, bool reuse) { Backend = backend; this.pool = pool; this.reuse = reuse; } /// /// Prevents the backend from being reclaimed /// public void PreventReuse() { reuse = false; } /// /// Disposes the backend wrapper /// public void Dispose() { if (disposed) return; disposed = true; if (reuse) pool.Enqueue(Backend); else try { Backend.Dispose(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "BackendDisposeError", ex, "Failed to dispose backend instance: {0}", ex.Message); } } } /// /// The handler for processing backend operations /// private class Handler { /// /// The tag used for logging /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The list of active downloads /// private readonly List activeDownloads = []; /// /// The list of active uploads /// private readonly List activeUploads = []; /// /// The pool of backends currently created /// private readonly ConcurrentQueue backendPool = new(); /// /// The URL of the backend /// private readonly string backendUrl; /// /// The context for the handler /// private readonly ExecuteContext context; /// /// The maximum number of parallel downloads /// private readonly int maxParallelDownloads; /// /// The maximum number of parallel uploads /// private readonly int maxParallelUploads; /// /// The maximum number of retries /// private readonly int maxRetries; /// /// The delay between retries /// private readonly TimeSpan retryDelay; /// /// Whether to retry with exponential backoff /// private readonly bool retryWithExponentialBackoff; /// /// Whether to allow backend reuse /// private readonly bool allowBackendReuse; /// /// Whether any files have been uploaded /// private bool anyUploaded; /// /// Whether any files have been downloaded /// private bool anyDownloaded; /// /// Creates and runs with an instance of the class /// /// The channel for pending operations /// The URL of the backend /// The execution context /// An awaitable task public static Task RunHandlerAsync(IReadChannel requestChannel, string backendUrl, ExecuteContext context) => AutomationExtensions.RunTask(new { requestChannel }, self => new Handler(backendUrl, context).Run(self.requestChannel) ); /// /// Creates a new instance of the class /// /// The URL of the backend /// The execution context private Handler(string backendUrl, ExecuteContext context) { this.backendUrl = backendUrl; this.context = context; // TODO Currently, only the restore process uses parallel downloads. If others need it as well, maybe use another option. maxParallelDownloads = Math.Max(1, context.Options.RestoreVolumeDownloaders); maxParallelUploads = Math.Max(1, context.Options.AsynchronousConcurrentUploadLimit); maxRetries = context.Options.NumberOfRetries; retryDelay = context.Options.RetryDelay; retryWithExponentialBackoff = context.Options.RetryWithExponentialBackoff; allowBackendReuse = !context.Options.NoConnectionReuse; } /// /// Creates a new backend instance or reuses an existing one /// /// The backend instance private ReclaimableBackend CreateBackend() { backendPool.TryDequeue(out var backend); if (backend == null) backend = DynamicLoader.BackendLoader.GetBackend(backendUrl, context.Options.RawOptions); return new ReclaimableBackend( backend, backendPool, allowBackendReuse ); } /// /// Reclaims completed tasks /// /// The list of tasks to reclaim /// An awaitable task private static async Task ReclaimCompletedTasks(List tasks) { for (int i = tasks.Count - 1; i >= 0; i--) { if (tasks[i].IsCompleted) { var t = tasks[i]; tasks.RemoveAt(i); // Make sure the task is awaited so we capture any exceptions await t.ConfigureAwait(false); } } } /// /// Reclaims completed tasks from uploads and downloads /// /// An awaitable task private async Task ReclaimCompletedTasks() { await ReclaimCompletedTasks(activeUploads); await ReclaimCompletedTasks(activeDownloads); } /// /// Ensures that there are at most N - 1 active tasks /// /// The maximum number of active tasks /// The list of active tasks /// An awaitable task private static async Task EnsureAtMostNActiveTasks(int n, List tasks) { while (tasks.Count >= n) { await Task.WhenAny(tasks).ConfigureAwait(false); await ReclaimCompletedTasks(tasks).ConfigureAwait(false); } } /// /// Ensures that there are at most N - 1 active tasks /// /// The number of active uploads /// The number of active downloads /// An awaitable task private async Task EnsureAtMostNActiveTasks(int uploads, int downloads) { await EnsureAtMostNActiveTasks(uploads, activeUploads).ConfigureAwait(false); await EnsureAtMostNActiveTasks(downloads, activeDownloads).ConfigureAwait(false); } /// /// Runs the handler /// /// The channel for pending operations /// An awaitable task private async Task Run(IReadChannel requestChannel) { using var tcs = new CancellationTokenSource(); try { while (true) { // Get next operation var op = await requestChannel.ReadAsync().ConfigureAwait(false); try { // Clean up completed uploads, if any await ReclaimCompletedTasks().ConfigureAwait(false); // Allow PUT operations to be queued, if requested if (op is PutOperation putOp && !putOp.WaitForComplete) { // Wait for any active downloads to complete before starting an upload await EnsureAtMostNActiveTasks(maxParallelUploads, 1).ConfigureAwait(false); // Operation is accepted into queue, so we can signal completion putOp.SetComplete(true); activeUploads.Add(ExecuteWithRetry(putOp, tcs.Token)); } else if (op is GetOperation getOp) { // Wait for any active uploads to complete before starting a download await EnsureAtMostNActiveTasks(1, maxParallelDownloads).ConfigureAwait(false); // Operation is accepted into queue, so we can signal completion activeDownloads.Add(ExecuteWithRetry(getOp, tcs.Token)); } else { // Wait for all of the active uploads and downloads to complete await EnsureAtMostNActiveTasks(1, 1).ConfigureAwait(false); // Execute the operation await ExecuteWithRetry(op, tcs.Token).ConfigureAwait(false); } } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerHandlerFailure", ex, "Error in handler: {0}", ex.Message); // If we fail, the task may "hang", so we ensure it is completed here op.SetFailed(ex); throw; } } } finally { // Terminate any active uploads and downloads. Exceptions thrown by the downloads should be captured by the callers. tcs.Cancel(); if (activeUploads.Count > 0) { Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeWhileActive", null, "Terminating {0} active uploads", activeUploads.Count); // Wait for all active uploads to complete await Task.WhenAny(Task.Delay(1000), Task.WhenAll(activeUploads)).ConfigureAwait(false); for (int i = activeUploads.Count - 1; i >= 0; i--) { var t = activeUploads[i]; if (t.IsCompleted) { activeUploads.RemoveAt(i); if (t.IsCanceled) Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", t.Exception, "Error in active upload: Cancelled"); else if (t.IsFaulted) Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", t.Exception, "Error in active upload: {0}", t.Exception?.Message ?? "null"); else Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "Upload was active during termination, but completed successfully"); } else { Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "Upload was active during termination, but had state: {0}", t.Status); } if (activeUploads.Count > 0) Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "Terminating, but {0} active uploads are still active", activeUploads.Count); } // Dispose of any remaining backends while (backendPool.TryDequeue(out var backend)) try { backend.Dispose(); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", ex, "Failed to dispose backend instance: {0}", ex.Message); } } } } /// /// Tries to create a folder, handling errors /// /// true if the folder was created, false otherwise private async Task TryCreateFolder() { using var backend = CreateBackend(); try { // If we successfully create the folder, we can re-use the connection await backend.Backend.CreateFolderAsync(context.TaskReader.TransferToken).ConfigureAwait(false); return true; } catch (Exception ex) { // Failure should not reuse the backend backend.PreventReuse(); Logging.Log.WriteWarningMessage(LOGTAG, "FolderCreateError", ex, "Failed to create folder: {0}", ex.Message); } return false; } /// /// Executes an operation with retries and error handling /// /// The operation to execute /// The cancellation token /// An awaitable task private async Task ExecuteWithRetry(PendingOperationBase op, CancellationToken cancellationToken) { // Once in this method, we MUST set the op result, // or the program will hang waiting for the operation to complete int retries = 0; Exception? lastException = null; do { try { // Happy case is execute and return await Execute(op, cancellationToken).ConfigureAwait(false); return; } catch (Exception ex) { retries++; lastException = ex; Logging.Log.WriteRetryMessage(LOGTAG, $"Retry{op.Operation}", ex, "Operation {0} with file {1} attempt {2} of {3} failed with message: {4}", op.Operation, op.RemoteFilename, retries, maxRetries, ex.Message); // If we are cancelled, stop retrying if (op.CancelToken.IsCancellationRequested || context.TaskReader.ProgressToken.IsCancellationRequested || context.TaskReader.TransferToken.IsCancellationRequested) { op.SetCancelled(); return; } // Refresh DNS name if we fail to connect in order to prevent issues with incorrect DNS entries var dnsFailure = Library.Utility.Utility.FlattenException(ex).Any(x => x is System.Net.WebException wex && wex.Status == System.Net.WebExceptionStatus.NameResolutionFailure); if (dnsFailure) { try { using (var backend = CreateBackend()) foreach (var name in await backend.Backend.GetDNSNamesAsync(context.TaskReader.TransferToken).ConfigureAwait(false) ?? []) if (!string.IsNullOrWhiteSpace(name)) System.Net.Dns.GetHostEntry(name); } catch { } } context.Statwriter.SendEvent(op.Operation, retries < maxRetries ? BackendEventType.Retrying : BackendEventType.Failed, op.RemoteFilename, op.Size); // Check if we can recover from the error var recovered = false; // Check if this was a folder missing exception and we are allowed to autocreate folders if (!(anyDownloaded || anyUploaded) && context.Options.AutocreateFolders && Library.Utility.Utility.FlattenException(ex).Any(x => x is FolderMissingException)) { if (await TryCreateFolder().ConfigureAwait(false)) recovered = true; } // We did not recover, so wait or give up if (!recovered && retries < maxRetries && retryDelay.Ticks != 0) { var delay = Library.Utility.Utility.GetRetryDelay(retryDelay, retries, retryWithExponentialBackoff); await Task.Delay(delay, context.TaskReader.ProgressToken).ConfigureAwait(false); } } } while (retries < maxRetries); // If we have a last exception, we failed if (lastException != null) { op.SetFailed(lastException); (op as IDisposable)?.Dispose(); // Stop processing tasks if the operation failed and is not being waited for // Delete operations can be retried later, so we don't stop processing if (!op.WaitForComplete && op is not DeleteOperation) throw lastException; } } /// /// Fan-out for executing operations. /// This method requires manual updates when new operation types are added, /// but avoids a reflection-based dispatch. /// /// The operation to execute /// The cancellation token /// An awaitable task private async Task Execute(PendingOperationBase op, CancellationToken cancellationToken) { await context.TaskReader.ProgressRendevouz().ConfigureAwait(false); using (new Logging.Timer(LOGTAG, $"RemoteOperation{op.Operation}", $"RemoteOperation{op.Operation}")) switch (op) { case PutOperation putOp: await Execute(putOp, cancellationToken).ConfigureAwait(false); anyUploaded = true; return; case GetOperation getOp: await Execute(getOp, cancellationToken).ConfigureAwait(false); anyDownloaded = true; return; case DeleteOperation deleteOp: await Execute(deleteOp, cancellationToken).ConfigureAwait(false); return; case ListOperation listOp: await Execute(listOp, cancellationToken).ConfigureAwait(false); return; case QuotaInfoOperation quotaOp: await Execute(quotaOp, cancellationToken).ConfigureAwait(false); return; case WaitForEmptyOperation waitOp: waitOp.SetComplete(true); return; default: throw new NotImplementedException($"Operation type {op.GetType()} is not supported"); } } /// /// Executes a specific operation /// /// The return value type of the operation /// The operation to execute /// The cancellation token /// An awaitable task private async Task Execute(PendingOperation op, CancellationToken cancellationToken) { using var backend = CreateBackend(); using var token = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, op.CancelToken, context.TaskReader.TransferToken); try { // Start processing the operation var task = op.ExecuteAsync(backend.Backend, token.Token); if (typeof(TResult) == typeof(bool) && !op.WaitForComplete) { // Operation is accepted into queue, so we can signal completion op.SetComplete((TResult)(object)true); await task.ConfigureAwait(false); } else { if (!op.WaitForComplete) throw new NotImplementedException($"WaitForComplete is required for operations returning a value: {op.GetType().FullName}"); // Wait for the operation to complete op.SetComplete(await task.ConfigureAwait(false)); } } catch { // If the operation fails, we prevent reuse of the backend backend.PreventReuse(); throw; } } } }