Files
duplicati/Duplicati/Library/Main/Operation/Backup/BackendUploader.cs
T
Kenneth Skovhede 586c08522e Implemented async method for Test, CreateFolder and Quota in all backends.
Still missing the List and DNSHostnames.
The AsyncRequest class is also preventing many backends from fully upgrading.
2024-10-01 09:30:02 +02:00

516 lines
22 KiB
C#

// Copyright (C) 2024, 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 CoCoL;
using Duplicati.Library.Interface;
using Duplicati.Library.Main.Operation.Common;
using Duplicati.Library.Main.Volumes;
using Duplicati.Library.Utility;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using static Duplicati.Library.Main.Operation.Common.BackendHandler;
namespace Duplicati.Library.Main.Operation.Backup
{
internal interface IUploadRequest
{
}
internal class FlushRequest : IUploadRequest
{
public Task<long> LastWriteSizeAsync { get { return m_tcs.Task; } }
private readonly TaskCompletionSource<long> m_tcs = new TaskCompletionSource<long>();
public void SetFlushed(long size)
{
m_tcs.TrySetResult(size);
}
public void TrySetCanceled()
{
m_tcs.TrySetCanceled();
}
}
internal class IndexVolumeUploadRequest : IUploadRequest
{
public IndexVolumeWriter IndexVolume { get; private set; }
public IndexVolumeUploadRequest(IndexVolumeWriter indexVolume)
{
IndexVolume = indexVolume;
}
}
internal class FilesetUploadRequest : IUploadRequest
{
public FilesetVolumeWriter Fileset { get; private set; }
public FilesetUploadRequest(FilesetVolumeWriter fileset)
{
Fileset = fileset;
}
}
internal class VolumeUploadRequest : IUploadRequest
{
public FileEntryItem BlockEntry { get; }
public BlockVolumeWriter BlockVolume { get; }
public TemporaryIndexVolume IndexVolume { get; }
public Options Options { get; }
public BackupDatabase Database { get; }
public VolumeUploadRequest(BlockVolumeWriter blockVolume, FileEntryItem blockEntry, TemporaryIndexVolume indexVolume, Options options, BackupDatabase database)
{
BlockVolume = blockVolume;
BlockEntry = blockEntry;
IndexVolume = indexVolume;
Options = options;
Database = database;
}
}
/// <summary>
/// This class encapsulates all requests to the backend
/// and ensures that the <code>AsynchronousUploadLimit</code> is honored
/// </summary>
internal class BackendUploader
{
private static readonly string LOGTAG = Logging.Log.LogTagFromType<BackendUploader>();
private readonly Func<IBackend> m_backendFactory;
private CancellationTokenSource m_cancelTokenSource;
private readonly DatabaseCommon m_database;
private long m_initialUploadThrottleSpeed;
private int m_lastThrottleCheckTime;
private long m_lastUploadThrottleSpeed;
private int m_maxConcurrentUploads;
private readonly Options m_options;
private readonly FileProgressThrottler m_progressUpdater;
private readonly StatsCollector m_stats;
private readonly ITaskReader m_taskReader;
public BackendUploader(Func<IBackend> backendFactory, Options options, DatabaseCommon database, ITaskReader taskReader, StatsCollector stats)
{
m_backendFactory = backendFactory;
m_options = options;
m_taskReader = taskReader;
m_stats = stats;
m_database = database;
m_progressUpdater = new FileProgressThrottler(stats, options.MaxUploadPrSecond);
}
public Task Run()
{
return AutomationExtensions.RunTask(new
{
Input = Channels.BackendRequest.ForRead,
},
async self =>
{
var workers = new List<Worker>();
m_maxConcurrentUploads = m_options.AsynchronousConcurrentUploadLimit <= 0 ? int.MaxValue : m_options.AsynchronousConcurrentUploadLimit;
m_initialUploadThrottleSpeed = m_options.AsynchronousConcurrentUploadLimit <= 0 ? int.MaxValue : m_options.MaxUploadPrSecond / m_maxConcurrentUploads;
var lastSize = -1L;
var uploadsInProgress = 0;
m_cancelTokenSource = new CancellationTokenSource();
m_progressUpdater.Run(m_cancelTokenSource.Token);
var filesetsToUpload = new List<FilesetUploadRequest>();
try
{
while (!await self.Input.IsRetiredAsync && await m_taskReader.ProgressAsync)
{
var req = await self.Input.ReadAsync();
if (!await m_taskReader.ProgressAsync)
break;
Worker worker = GetWorker(workers);
if (req is VolumeUploadRequest volumeUpload)
{
if (volumeUpload.IndexVolume == null)
worker.Task = Task.Run(() => UploadFileAsync(volumeUpload.BlockEntry, worker, m_cancelTokenSource.Token));
else
worker.Task = Task.Run(() => UploadBlockAndIndexAsync(volumeUpload, worker, m_cancelTokenSource.Token));
lastSize = volumeUpload.BlockVolume.SourceSize;
uploadsInProgress++;
}
else if (req is FilesetUploadRequest filesetUpload)
{
filesetsToUpload.Add(filesetUpload);
}
else if (req is IndexVolumeUploadRequest indexUpload)
{
worker.Task = Task.Run(() => UploadVolumeWriter(indexUpload.IndexVolume, worker, m_cancelTokenSource.Token));
uploadsInProgress++;
}
else if (req is FlushRequest flush)
{
await WaitForWorkers(workers, flush).ConfigureAwait(false);
UploadFilesets(workers, filesetsToUpload);
await WaitForWorkers(workers, flush).ConfigureAwait(false);
flush.SetFlushed(lastSize);
uploadsInProgress = 0;
break;
}
if (uploadsInProgress >= m_maxConcurrentUploads)
{
await Task.WhenAny(workers.Select(w => w.Task)).ConfigureAwait(false);
uploadsInProgress--;
var failedUploads = workers.Where(w => w.Task.IsFaulted).Select(w => GetInnerMostException(w.Task.Exception)).ToList();
if (failedUploads.Any())
{
if (failedUploads.Count == 1)
ExceptionDispatchInfo.Capture(failedUploads.First()).Throw();
else
throw new AggregateException(failedUploads);
}
}
}
}
catch (Exception ex) when (!ex.IsRetiredException())
{
m_cancelTokenSource.Cancel();
try
{
await Task.WhenAll(workers.Select(w => w.Task));
}
catch
{
// As we are cancelling all threads we do not need to alert the user to any of these exceptions.
}
finally
{
workers.ForEach(w => w.Dispose());
}
throw;
}
try
{
m_stats.SetBlocking(true);
await Task.WhenAll(workers.Select(w => w.Task));
}
finally
{
m_stats.SetBlocking(false);
workers.ForEach(w => w.Dispose());
}
});
}
private Worker GetWorker(List<Worker> workers)
{
var worker = workers.FirstOrDefault(w => w.Task.IsCompleted && !w.Task.IsFaulted);
if (worker == null)
{
worker = new Worker(m_backendFactory());
workers.Add(worker);
}
return worker;
}
private async Task WaitForWorkers(List<Worker> workers, FlushRequest flush)
{
while (workers.Any())
{
Task finishedTask = await Task.WhenAny(workers.Select(w => w.Task)).ConfigureAwait(false);
if (finishedTask.IsFaulted)
{
flush.TrySetCanceled();
ExceptionDispatchInfo.Capture(finishedTask.Exception).Throw();
}
Worker finishedWorker = workers.Single(w => w.Task == finishedTask);
workers.Remove(finishedWorker);
finishedWorker.Dispose();
}
}
private void UploadFilesets(List<Worker> workers, List<FilesetUploadRequest> filesets)
{
foreach (var fileset in filesets)
{
Worker worker = GetWorker(workers);
worker.Task = Task.Run(() => UploadVolumeWriter(fileset.Fileset, worker, m_cancelTokenSource.Token));
}
filesets.Clear();
}
private static Exception GetInnerMostException(Exception ex)
{
while (ex.InnerException != null)
ex = ex.InnerException;
return ex;
}
private async Task UploadBlockAndIndexAsync(VolumeUploadRequest upload, Worker worker, CancellationToken cancelToken)
{
if (await UploadFileAsync(upload.BlockEntry, worker, cancelToken).ConfigureAwait(false))
{
// We must use the BlockEntry's RemoteFilename and not the BlockVolume's since the
// BlockEntry's' RemoteFilename reflects renamed files due to retries after errors.
IndexVolumeWriter indexVolumeWriter = await upload.IndexVolume.CreateVolume(upload.BlockEntry.RemoteFilename, upload.BlockEntry.Hash, upload.BlockEntry.Size, upload.Options, upload.Database);
FileEntryItem indexEntry = indexVolumeWriter.CreateFileEntryForUpload(upload.Options);
if (await UploadFileAsync(indexEntry, worker, cancelToken).ConfigureAwait(false))
{
await m_database.AddIndexBlockLinkAsync(indexVolumeWriter.VolumeID, upload.BlockVolume.VolumeID).ConfigureAwait(false);
}
}
}
private async Task UploadVolumeWriter(VolumeWriterBase volumeWriter, Worker worker, CancellationToken cancelToken)
{
var fileEntry = new FileEntryItem(BackendActionType.Put, volumeWriter.RemoteFilename);
fileEntry.SetLocalfilename(volumeWriter.LocalFilename);
fileEntry.Encrypt(m_options);
fileEntry.UpdateHashAndSize(m_options);
await UploadFileAsync(fileEntry, worker, cancelToken).ConfigureAwait(false);
}
private async Task<bool> UploadFileAsync(FileEntryItem item, Worker worker, CancellationToken cancelToken)
{
if (cancelToken.IsCancellationRequested)
return false;
return await DoWithRetry(async () =>
{
if (item.IsRetry)
await RenameFileAfterErrorAsync(item).ConfigureAwait(false);
await DoPut(item, worker.Backend, cancelToken).ConfigureAwait(false);
},
item, worker, cancelToken).ConfigureAwait(false);
}
private async Task<bool> DoWithRetry(Func<Task> method, FileEntryItem item, Worker worker, CancellationToken cancelToken)
{
item.IsRetry = false;
var retryCount = 0;
for (retryCount = 0; retryCount <= m_options.NumberOfRetries; retryCount++)
{
if (m_options.RetryDelay.Ticks != 0 && retryCount != 0)
{
var delay = Library.Utility.Utility.GetRetryDelay(m_options.RetryDelay, retryCount, m_options.RetryWithExponentialBackoff);
await Task.Delay(delay).ConfigureAwait(false);
}
if (cancelToken.IsCancellationRequested)
return false;
try
{
if (worker.Backend == null)
worker.Backend = m_backendFactory();
await method().ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
item.IsRetry = true;
Logging.Log.WriteRetryMessage(LOGTAG, $"Retry{item.Operation}", ex, "Operation {0} with file {1} attempt {2} of {3} failed with message: {4}", item.Operation, item.RemoteFilename, retryCount + 1, m_options.NumberOfRetries + 1, ex.Message);
if (ex is ThreadAbortException || ex is OperationCanceledException)
break;
await m_stats.SendEventAsync(item.Operation, retryCount < m_options.NumberOfRetries ? BackendEventType.Retrying : BackendEventType.Failed, item.RemoteFilename, item.Size);
bool recovered = false;
if (m_options.AutocreateFolders && ex is FolderMissingException)
{
try
{
// If we successfully create the folder, we can re-use the connection
await worker.Backend.CreateFolderAsync(cancelToken).ConfigureAwait(false);
recovered = true;
}
catch (Exception dex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "FolderCreateError", dex, "Failed to create folder: {0}", ex.Message);
}
}
if (!recovered)
ResetBackend(ex, worker);
if (retryCount == m_options.NumberOfRetries)
throw;
}
finally
{
if (m_options.NoConnectionReuse)
ResetBackend(null, worker);
}
}
return false;
}
private void ResetBackend(Exception ex, Worker worker)
{
try
{
worker.Backend?.Dispose();
}
catch (Exception dex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "BackendDisposeError", dex, "Failed to dispose backend instance: {0}", ex?.Message);
}
finally
{
worker.Backend = null;
}
}
private async Task RenameFileAfterErrorAsync(FileEntryItem item)
{
var p = VolumeBase.ParseFilename(item.RemoteFilename);
var guid = VolumeWriterBase.GenerateGuid();
var time = p.Time.Ticks == 0 ? p.Time : p.Time.AddSeconds(1);
var newname = VolumeBase.GenerateFilename(p.FileType, p.Prefix, guid, time, p.CompressionModule, p.EncryptionModule);
var oldname = item.RemoteFilename;
await m_stats.SendEventAsync(item.Operation, BackendEventType.Rename, oldname, item.Size);
await m_stats.SendEventAsync(item.Operation, BackendEventType.Rename, newname, item.Size);
Logging.Log.WriteInformationMessage(LOGTAG, "RenameRemoteTargetFile", "Renaming \"{0}\" to \"{1}\"", oldname, newname);
await m_database.RenameRemoteFileAsync(oldname, newname);
item.RemoteFilename = newname;
}
private async Task DoPut(FileEntryItem item, IBackend backend, CancellationToken cancelToken)
{
if (cancelToken.IsCancellationRequested)
return;
if (item.TrackedInDb)
await m_database.UpdateRemoteVolumeAsync(item.RemoteFilename, RemoteVolumeState.Uploading, item.Size, item.Hash);
if (m_options.Dryrun)
{
Logging.Log.WriteDryrunMessage(LOGTAG, "WouldUploadVolume", "Would upload volume: {0}, size: {1}", item.RemoteFilename, Library.Utility.Utility.FormatSizeString(new FileInfo(item.LocalFilename).Length));
item.DeleteLocalFile();
return;
}
await m_database.LogRemoteOperationAsync("put", item.RemoteFilename, JsonConvert.SerializeObject(new { Size = item.Size, Hash = item.Hash }));
await m_stats.SendEventAsync(BackendActionType.Put, BackendEventType.Started, item.RemoteFilename, item.Size, updateProgress: false);
m_progressUpdater.StartFileProgress(item.RemoteFilename, item.Size);
var begin = DateTime.Now;
if (!m_options.DisableStreamingTransfers && backend is IStreamingBackend streamingBackend)
{
// A download throttle speed is not given to the ThrottledStream as we are only uploading data here
using (var fs = File.OpenRead(item.LocalFilename))
using (var ts = new ThrottledStream(fs, m_initialUploadThrottleSpeed, 0))
using (var pgs = new ProgressReportingStream(ts, pg => HandleProgress(ts, pg, item.RemoteFilename)))
await streamingBackend.PutAsync(item.RemoteFilename, pgs, cancelToken).ConfigureAwait(false);
}
else
await backend.PutAsync(item.RemoteFilename, item.LocalFilename, cancelToken).ConfigureAwait(false);
var duration = DateTime.Now - begin;
m_progressUpdater.EndFileProgress(item.RemoteFilename);
Logging.Log.WriteProfilingMessage(LOGTAG, "UploadSpeed", "Uploaded {0} in {1}, {2}/s", Library.Utility.Utility.FormatSizeString(item.Size), duration, Library.Utility.Utility.FormatSizeString((long)(item.Size / duration.TotalSeconds)));
if (item.TrackedInDb)
await m_database.UpdateRemoteVolumeAsync(item.RemoteFilename, RemoteVolumeState.Uploaded, item.Size, item.Hash);
await m_stats.SendEventAsync(BackendActionType.Put, BackendEventType.Completed, item.RemoteFilename, item.Size);
if (m_options.ListVerifyUploads)
{
var f = backend.List().FirstOrDefault(n => n.Name.Equals(item.RemoteFilename, StringComparison.OrdinalIgnoreCase));
if (f == null)
throw new Exception(string.Format("List verify failed, file was not found after upload: {0}", item.RemoteFilename));
else if (f.Size != item.Size && f.Size >= 0)
throw new Exception(string.Format("List verify failed for file: {0}, size was {1} but expected to be {2}", f.Name, f.Size, item.Size));
}
item.DeleteLocalFile();
await m_database.CommitTransactionAsync("CommitAfterUpload");
}
private void HandleProgress(ThrottledStream stream, long progress, string path)
{
UpdateThrottleSpeed();
m_progressUpdater.UpdateFileProgress(path, progress, stream);
}
private void UpdateThrottleSpeed()
{
var updateThrottleSpeed = false;
lock (m_progressUpdater)
{
var currentTick = Environment.TickCount;
if (currentTick - m_lastThrottleCheckTime > 2000)
{
m_lastThrottleCheckTime = currentTick;
updateThrottleSpeed = true;
}
}
if (!updateThrottleSpeed)
return;
var maxUploadPerSecond = m_options.MaxUploadPrSecond;
if (maxUploadPerSecond != m_lastUploadThrottleSpeed)
{
m_lastUploadThrottleSpeed = maxUploadPerSecond;
m_initialUploadThrottleSpeed = maxUploadPerSecond / m_maxConcurrentUploads;
m_progressUpdater.UpdateThrottleSpeeds(maxUploadPerSecond);
}
}
private class Worker : IDisposable
{
public Task Task;
public IBackend Backend;
public Worker(IBackend backend)
{
Backend = backend;
Task = Task.FromResult(true);
}
public void Dispose()
{
this.Task?.Dispose();
this.Backend?.Dispose();
}
}
}
}