2025-08-05 11:07:19 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
2025-08-05 13:05:10 +02:00
using System.Linq ;
2025-08-05 11:07:19 +02:00
using System.Threading ;
using System.Threading.Tasks ;
using Duplicati.Library.Interface ;
using Duplicati.Library.Utility ;
namespace RemoteSynchronization
{
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 13:05:10 +02:00
public class LightWeightBackendManager ( string backendUrl , Dictionary < string , string > options , int maxRetries = 3 , int retryDelay = 1000 , bool autoCreateFolders = false , bool retryWithExponentialBackoff = false ) : IDisposable
2025-08-05 11:07:19 +02:00
{
private static readonly string LOGTAG = Duplicati . Library . Logging . Log . LogTagFromType < Program >();
public IBackend ? _backend = null ;
2025-08-05 13:05:10 +02:00
private bool _anyDownloaded = false ;
private bool _anyUploaded = false ;
2025-08-05 11:07:19 +02:00
private readonly string _backendUrl = backendUrl ;
2025-08-07 21:06:48 +02:00
//private int _instantiations = 0;
2025-08-05 11:07:19 +02:00
private readonly int _maxRetries = maxRetries ;
2025-08-05 19:38:38 +02:00
private readonly Dictionary < string , string > _options = options ;
2025-08-05 13:05:10 +02:00
private int _retryDelay = retryDelay ;
2025-08-07 21:06:48 +02:00
private int _currentRetryDelay = retryDelay ;
2025-08-05 11:07:19 +02:00
private IStreamingBackend ? _streamingBackend = null ;
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 11:07:19 +02:00
public Task DeleteAsync ( string remotename , CancellationToken token )
{
return RetryWithDelay (
$"Delete {remotename}" ,
2025-08-05 19:38:38 +02:00
() => _streamingBackend !. DeleteAsync ( remotename , token ),
2025-08-05 15:07:52 +02:00
null ,
false ,
2025-08-05 13:05:10 +02:00
token
2025-08-05 11:07:19 +02:00
);
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 11:07:19 +02:00
public string DisplayName
{
get
{
2025-08-05 13:05:10 +02:00
Instantiate ();
2025-08-05 11:07:19 +02:00
2025-08-05 19:38:38 +02:00
return _streamingBackend !. DisplayName ;
2025-08-05 11:07:19 +02:00
}
}
2025-08-05 20:09:09 +02:00
/// <summary>
/// Disposes of the backend and streaming backend resources.
/// </summary>
2025-08-05 11:07:19 +02:00
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 );
}
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 11:07:19 +02:00
public Task GetAsync ( string remotename , Stream stream , CancellationToken token )
{
return RetryWithDelay (
$"Get {remotename}" ,
2025-08-05 15:07:52 +02:00
async () =>
{
2025-08-05 19:38:38 +02:00
await _streamingBackend !. GetAsync ( remotename , stream , token ). ConfigureAwait ( false );
2025-08-05 15:07:52 +02:00
_anyDownloaded = true ;
},
stream ,
true ,
2025-08-05 13:05:10 +02:00
token
2025-08-05 11:07:19 +02:00
);
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 11:07:19 +02:00
private void Instantiate ()
{
2025-08-05 13:05:10 +02:00
if ( _backend != null )
{
// If we already have a backend, we can just return.
return ;
}
2025-08-07 21:02:39 +02:00
_backend = Duplicati . Library . DynamicLoader . BackendLoader . GetBackend ( _backendUrl , _options );
_streamingBackend = _backend as IStreamingBackend ;
2025-11-03 12:48:37 +01:00
if ( _streamingBackend == null || ! _streamingBackend . SupportsStreaming )
2025-08-05 11:07:19 +02:00
{
2025-08-07 21:02:39 +02:00
_backend . Dispose ();
_backend = null ;
throw new InvalidOperationException ( "Backend does not support streaming operations." );
2025-08-05 11:07:19 +02:00
}
}
2025-08-05 20:09:09 +02:00
/// <summary>
/// Lists the files in the remote backend asynchronously.
/// </summary>
/// <param name="token">A cancellation token to cancel the operation.</param>
2025-08-07 21:05:35 +02:00
/// <returns>A list of the file entries on the remote backend.</returns>
public async Task < List < IFileEntry >> ListAsync ( CancellationToken token )
2025-08-05 11:07:19 +02:00
{
2025-08-07 21:05:35 +02:00
// 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 RetryWithDelay ( "List" , async () =>
2025-08-05 11:07:19 +02:00
{
2025-08-07 21:05:35 +02:00
entries = await _streamingBackend !. ListAsync ( token ). ToListAsync (). ConfigureAwait ( false );
},
null ,
false ,
token )
. ConfigureAwait ( false );
2025-08-05 11:07:19 +02:00
2025-08-07 21:05:35 +02:00
return entries ;
2025-08-05 11:07:19 +02:00
}
2025-08-05 20:09:09 +02:00
/// <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>
/// <returns>A task representing the asynchronous put operation.</returns>
2025-08-05 11:07:19 +02:00
public Task PutAsync ( string remotename , Stream stream , CancellationToken token )
{
return RetryWithDelay (
$"Put {remotename}" ,
2025-08-05 15:07:52 +02:00
async () =>
{
2025-08-05 19:38:38 +02:00
await _streamingBackend !. PutAsync ( remotename , stream , token ). ConfigureAwait ( false );
2025-08-05 15:07:52 +02:00
_anyUploaded = true ;
},
stream ,
false ,
2025-08-05 13:05:10 +02:00
token
2025-08-05 11:07:19 +02:00
);
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 11:07:19 +02:00
public Task RenameAsync ( string oldname , string newname , CancellationToken token )
{
2025-08-05 13:05:10 +02:00
Instantiate ();
2025-08-05 11:07:19 +02:00
return _backend switch
{
IStreamingBackend sb =>
RetryWithDelay (
$"Rename {oldname} to {newname}" ,
async () =>
{
// Download the file, rename it, and delete the old one
using var downloaded = new MemoryStream ();
2025-08-05 13:05:10 +02:00
await sb . GetAsync ( oldname , downloaded , token ). ConfigureAwait ( false );
await sb . PutAsync ( newname , downloaded , token ). ConfigureAwait ( false );
await sb . DeleteAsync ( oldname , token ). ConfigureAwait ( false );
_anyUploaded = true ;
_anyDownloaded = true ;
},
2025-08-05 15:07:52 +02:00
null ,
false ,
2025-08-05 13:05:10 +02:00
token
2025-08-05 11:07:19 +02:00
),
IRenameEnabledBackend ireb =>
RetryWithDelay (
$"Rename {oldname} to {newname}" ,
2025-08-05 15:07:52 +02:00
async () =>
{
await ireb . RenameAsync ( oldname , newname , token ). ConfigureAwait ( false );
_anyUploaded = true ;
_anyDownloaded = true ;
},
null ,
false ,
2025-08-05 13:05:10 +02:00
token
2025-08-05 11:07:19 +02:00
),
_ => throw new InvalidOperationException ( "Backend does not support renaming." ),
};
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 15:07:52 +02:00
private async Task RetryWithDelay ( string operationName , Func < Task > action , Stream ? stream , bool resetStream , CancellationToken token )
2025-08-05 11:07:19 +02:00
{
2025-08-07 21:02:39 +02:00
int instantiations = 0 ;
2025-08-07 21:06:48 +02:00
_currentRetryDelay = _retryDelay ; // Reset the current retry delay to the initial value
2025-08-07 21:02:39 +02:00
do
2025-08-05 11:07:19 +02:00
{
2025-08-05 13:05:10 +02:00
// This will throw an exception if we've reached the max
// number of retries.
Instantiate ();
2025-08-07 21:02:39 +02:00
instantiations ++;
2025-08-05 11:07:19 +02:00
try
{
2025-08-06 07:30:35 +02:00
await action (). ConfigureAwait ( false );
2025-08-05 13:05:10 +02:00
return ; // Exit the loop if the action succeeds.
2025-08-05 11:07:19 +02:00
}
catch ( Exception ex )
{
Duplicati . Library . Logging . Log . WriteErrorMessage ( LOGTAG , "rsync" , ex , "Error during operation: {0}" , operationName );
2025-08-05 13:05:10 +02:00
Dispose (); // Dispose current backend and streaming backend.
2025-08-05 15:07:52 +02:00
// Reset the stream, as it's in a potentially faulty state.
2025-11-05 08:48:19 +01:00
if ( stream != null && stream . CanSeek )
{
stream . Seek ( 0 , SeekOrigin . Begin );
if ( resetStream )
stream . SetLength ( 0 );
}
2025-08-05 15:07:52 +02:00
2025-08-05 13:05:10 +02:00
// Try to see if we can recover from the error.
await TryRecoverFromException ( ex , token ). ConfigureAwait ( false );
}
2025-08-07 21:02:39 +02:00
} while ( instantiations < _maxRetries );
// If we reach here, it means all retries failed.
throw new InvalidOperationException ( $"Operation '{operationName}' failed after {instantiations} attempts." );
2025-08-05 13:05:10 +02:00
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 13:05:10 +02:00
private async Task < bool > TryCreateFolder ( CancellationToken token )
{
2025-08-07 21:03:05 +02:00
bool created = false ;
await RetryWithDelay ( "CreateFolder" , async () =>
{
try
{
await _backend !. CreateFolderAsync ( token ). ConfigureAwait ( false );
created = true ; // Folder creation succeeded
}
catch
{
created = false ; // Folder creation failed
}
},
null , false , token );
2025-08-05 13:05:10 +02:00
2025-08-07 21:03:05 +02:00
return created ;
2025-08-05 13:05:10 +02:00
}
2025-08-05 20:09:09 +02:00
/// <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>
2025-08-05 13:05:10 +02:00
private async Task TryRecoverFromException ( 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 ();
2025-08-05 19:38:38 +02:00
foreach ( var name in await _backend !. GetDNSNamesAsync ( token ). ConfigureAwait ( false ) ?? [])
2025-08-05 13:05:10 +02:00
if (! string . IsNullOrWhiteSpace ( name ))
System . Net . Dns . GetHostEntry ( name );
2025-08-05 11:07:19 +02:00
}
2025-08-05 13:05:10 +02:00
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 TryCreateFolder ( token ). ConfigureAwait ( false ))
recovered = true ;
}
// Finally, if we did not recover, wait the specified delay before retrying.
if (! recovered && _retryDelay > 0 )
{
2025-08-07 21:06:48 +02:00
await Task . Delay ( _currentRetryDelay , token ). ConfigureAwait ( false );
2025-08-05 13:05:10 +02:00
2025-08-07 21:06:48 +02:00
if ( retryWithExponentialBackoff )
_currentRetryDelay <<= 1 ; // Double the delay for exponential backoff
2025-08-05 11:07:19 +02:00
}
}
}
}