#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 : IDisposable
{
///
/// 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 },
async self =>
{
using var handler = new Handler(backendUrl, context);
await handler.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)
{
context.ProgressHandler?.SetIsBlocking(true);
await EnsureAtMostNActiveTasks(uploads, activeUploads).ConfigureAwait(false);
await EnsureAtMostNActiveTasks(downloads, activeDownloads).ConfigureAwait(false);
context.ProgressHandler?.SetIsBlocking(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();
await WaitForPendingItems("upload", activeUploads).ConfigureAwait(false);
await WaitForPendingItems("download", activeDownloads).ConfigureAwait(false);
// 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); }
}
}
///
///
///
///
///
///
private static async Task WaitForPendingItems(string description, List tasks)
{
// If we have tasks that have completed successfully, remove them from the list
// as they should not trigger any warnings
for (var i = tasks.Count - 1; i >= 0; i--)
if (tasks[i].IsCompletedSuccessfully)
tasks.RemoveAt(i);
if (tasks.Count > 0)
{
Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeWhileActive", null, "Terminating {0} active {1}s", tasks.Count, description);
// Wait for all active tasks to complete
await Task.WhenAny(Task.Delay(1000), Task.WhenAll(tasks)).ConfigureAwait(false);
for (int i = tasks.Count - 1; i >= 0; i--)
{
var t = tasks[i];
if (t.IsCompleted)
{
tasks.RemoveAt(i);
if (t.IsCanceled)
Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", t.Exception, "Error in active {0}: Cancelled", description);
else if (t.IsFaulted)
Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", t.Exception, "Error in active {0}: {1}", description, t.Exception?.Message ?? "null");
else
Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "{0} was active during termination, but completed successfully", description);
}
else
{
Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "{0} was active during termination, but had state: {1}", description, t.Status);
}
if (tasks.Count > 0)
Logging.Log.WriteWarningMessage(LOGTAG, "BackendManagerDisposeError", null, "Terminating, but {0} active {1}(s) are still active", tasks.Count, description);
}
}
}
///
/// 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.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
{
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.ExceptionExtensions.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);
using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, context.TaskReader.ProgressToken, context.TaskReader.TransferToken, context.TaskReader.StopToken);
await Task.Delay(delay, ct.Token).ConfigureAwait(false);
}
}
if (!await context.TaskReader.ProgressRendevouz())
{
op.SetCancelled();
return;
}
} 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)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(lastException).Throw();
}
}
///
/// 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;
}
}
public void Dispose()
{
while (backendPool.TryDequeue(out var backend)) backend?.Dispose();
}
}
}