2026-04-16 15:21:24 +02:00
// Copyright (C) 2026, The Duplicati Team
2025-12-14 17:17:48 +01:00
// 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 System ;
using System.IO ;
using System.Linq ;
using System.Threading ;
using System.Threading.Tasks ;
2025-12-17 22:27:38 +01:00
using Duplicati.Library.Interface ;
2025-12-14 17:17:48 +01:00
using Duplicati.Library.Main ;
using Duplicati.Library.Main.Database ;
2025-12-17 22:27:38 +01:00
using Duplicati.Library.Main.Operation ;
using Duplicati.Library.Utility ;
2025-12-14 17:17:48 +01:00
using NUnit.Framework ;
2026-01-06 10:22:53 +01:00
#nullable enable
2025-12-14 17:17:48 +01:00
namespace Duplicati.UnitTest
{
public class LockingDeleteAndCompactTests : BasicSetupHelper
{
2025-12-17 22:27:38 +01:00
private sealed class FakeLockingBackendManager : IBackendManager
{
public bool SupportsObjectLocking => true ;
public Task SetObjectLockUntilAsync ( string remotename , DateTime lockUntilUtc , CancellationToken cancelToken )
=> Task . CompletedTask ;
public Task < DateTime ?> GetObjectLockUntilAsync ( string remotename , CancellationToken cancelToken )
=> Task . FromResult < DateTime ?>( null );
public Task WaitForEmptyAsync ( CancellationToken cancellationToken )
=> Task . CompletedTask ;
public Task WaitForEmptyAsync ( LocalDatabase database , CancellationToken cancellationToken )
=> Task . CompletedTask ;
public void Dispose () { }
#region Unused interface members
public Task PutAsync ( Duplicati . Library . Main . Volumes . VolumeWriterBase blockVolume , Duplicati . Library . Main . Volumes . IndexVolumeWriter ? indexVolume , Func < Task >? indexVolumeFinished , bool waitForComplete , Func < Task >? onDbUpdate , CancellationToken cancelToken ) => throw new NotImplementedException ();
public Task PutVerificationFileAsync ( string remotename , TempFile tempFile , CancellationToken cancelToken ) => throw new NotImplementedException ();
public Task < System . Collections . Generic . IEnumerable < Duplicati . Library . Interface . IFileEntry >> ListAsync ( CancellationToken cancelToken ) => throw new NotImplementedException ();
2026-05-14 17:59:49 +02:00
public TempFile DecryptFile ( TempFile volume , string volume_name , Options options , bool dispose ) => throw new NotImplementedException ();
2025-12-17 22:27:38 +01:00
public Task DeleteAsync ( string remotename , long size , bool waitForComplete , CancellationToken cancelToken ) => throw new NotImplementedException ();
public Task < IQuotaInfo ?> GetQuotaInfoAsync ( CancellationToken cancelToken ) => throw new NotImplementedException ();
public Task <( TempFile File , string Hash , long Size )> GetWithInfoAsync ( string remotename , string hash , long size , CancellationToken cancelToken ) => throw new NotImplementedException ();
public Task < TempFile > GetAsync ( string remotename , string hash , long size , CancellationToken cancelToken ) => throw new NotImplementedException ();
public Task < TempFile > GetDirectAsync ( string remotename , string hash , long size , CancellationToken cancelToken ) => throw new NotImplementedException ();
public System . Collections . Generic . IAsyncEnumerable <( TempFile File , string Hash , long Size , string Name )> GetFilesOverlappedAsync ( System . Collections . Generic . IEnumerable < IRemoteVolume > volumes , CancellationToken cancelToken ) => throw new NotImplementedException ();
2026-05-14 17:59:49 +02:00
public System . Collections . Generic . IAsyncEnumerable <( TempFile File , string Name )> GetFilesOverlappedDirectAsync ( System . Collections . Generic . IEnumerable < IRemoteVolume > volumes , CancellationToken cancelToken ) => throw new NotImplementedException ();
2025-12-17 22:27:38 +01:00
public Task FlushPendingMessagesAsync ( LocalDatabase database , CancellationToken cancellationToken ) => Task . CompletedTask ;
public void UpdateThrottleValues ( long maxUploadPrSecond , long maxDownloadPrSecond ) => throw new NotImplementedException ();
#endregion
}
2025-12-14 17:17:48 +01:00
private static void SleepUntilNextSecond ( DateTime prevTimestamp )
{
// Sleep until the next second to make sure the next backup is not the same.
var nextSecond = prevTimestamp . AddMilliseconds (- prevTimestamp . Millisecond ). AddSeconds ( 2 );
var remainder = Math . Max ( 2000 , ( nextSecond - DateTime . Now ). TotalMilliseconds );
if ( remainder > 0 )
Thread . Sleep ( TimeSpan . FromMilliseconds ( remainder ));
}
[Test]
[Category("DeleteHandler")]
2026-05-13 15:04:13 +02:00
public async Task DeleteSkipsLockedRemoteFilesetVolumeAsync ()
2025-12-14 17:17:48 +01:00
{
var backupOpts = TestOptions ;
var target = "file://" + TARGETFOLDER ;
var file1 = Path . Combine ( DATAFOLDER , "f1" );
var file2 = Path . Combine ( DATAFOLDER , "f2" );
TestUtils . WriteTestFile ( file1 , 1024 );
string oldestFilesetVolumeName ;
using ( var c = new Controller ( target , backupOpts , null ))
{
2026-05-13 15:04:13 +02:00
var b1 = await c . BackupAsync ( new [] { DATAFOLDER });
2025-12-14 17:17:48 +01:00
Assert . That ( b1 . Errors , Is . Empty , "Backup 1 had errors" );
SleepUntilNextSecond ( b1 . BeginTime );
TestUtils . WriteTestFile ( file2 , 1024 );
2026-05-13 15:04:13 +02:00
var b2 = await c . BackupAsync ( new [] { DATAFOLDER });
2025-12-14 17:17:48 +01:00
Assert . That ( b2 . Errors , Is . Empty , "Backup 2 had errors" );
// Find the oldest fileset volume, and lock it.
var token = CancellationToken . None ;
await using ( var db = await LocalDatabase . CreateLocalDatabaseAsync ( DBFILE , null , true , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
var filesets = await db . FilesetTimesAsync ( token )
2025-12-14 17:17:48 +01:00
. ToArrayAsync ( cancellationToken : token )
. ConfigureAwait ( false );
Assert . That ( filesets . Length , Is . GreaterThanOrEqualTo ( 2 ), "Expected at least 2 filesets" );
var oldestFilesetId = filesets . Last (). Key ;
2026-05-13 15:04:13 +02:00
var oldestFilesetVolume = await db . GetRemoteVolumeFromFilesetIDAsync ( oldestFilesetId , token ). ConfigureAwait ( false );
2025-12-14 17:17:48 +01:00
oldestFilesetVolumeName = oldestFilesetVolume . Name ;
}
Assert . That ( oldestFilesetVolumeName , Is . Not . Null . And . Not . Empty , "Oldest fileset volume name was empty" );
await using ( var lockDb = await LocalLockDatabase . CreateAsync ( DBFILE , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
await lockDb . UpdateRemoteVolumeLockExpirationAsync ( oldestFilesetVolumeName , DateTime . UtcNow . AddHours ( 1 ), token ). ConfigureAwait ( false );
2025-12-14 17:17:48 +01:00
await lockDb . Transaction . CommitAsync ( token : token ). ConfigureAwait ( false );
}
}
// Run delete in a separate controller instance to avoid retention settings impacting the backup steps.
var deleteOpts = new System . Collections . Generic . Dictionary < string , string >( backupOpts )
{
["keep-versions"] = "1" ,
["no-auto-compact"] = "true" ,
};
using ( var cdelete = new Controller ( target , deleteOpts , null ))
{
2026-05-13 15:04:13 +02:00
var deleteResults = await cdelete . DeleteAsync ();
2025-12-14 17:17:48 +01:00
Assert . That ( deleteResults . Errors , Is . Empty , "Delete had errors" );
Assert . That ( deleteResults . Warnings . Any ( x => x . Contains ( "fileset volume has an active lock" , StringComparison . Ordinal )),
Is . True ,
"Expected a warning about skipping deletion due to lock" );
var remotePath = Path . Combine ( TARGETFOLDER , oldestFilesetVolumeName );
Assert . That ( File . Exists ( remotePath ), Is . True , $"Expected locked remote fileset volume to remain on backend: {remotePath}" );
}
}
[Test]
[Category("DeleteHandler")]
2026-05-13 15:04:13 +02:00
public async Task DeleteHonorsFilesetLocksFromBackupUntilExpirationAsync ()
2025-12-14 17:17:48 +01:00
{
2025-12-17 22:27:38 +01:00
// These values are chosen to give the backup and delete operations enough time to complete
// while still leaving a window where the lock is active. They can be adjusted in CI if
// the environment is significantly slower or faster.
const string LockDurationOptionValue = "10s" ;
var delayBetweenBackups = TimeSpan . FromSeconds ( 5 );
var lockPollInterval = TimeSpan . FromSeconds ( 1 );
var lockWaitTimeout = TimeSpan . FromSeconds ( 60 );
var backupOpts = new System . Collections . Generic . Dictionary < string , string >( TestOptions );
backupOpts [ "remote-file-lock-duration" ] = LockDurationOptionValue ;
backupOpts [ "no-auto-compact" ] = "true" ;
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
var target = "file://" + TARGETFOLDER ;
2025-12-14 17:17:48 +01:00
var file1 = Path . Combine ( DATAFOLDER , "f1" );
var file2 = Path . Combine ( DATAFOLDER , "f2" );
2025-12-17 22:27:38 +01:00
TestUtils . WriteTestFile ( file1 , 1024 );
2025-12-14 17:17:48 +01:00
long oldestFilesetId ;
2025-12-17 22:27:38 +01:00
DateTime oldestFilesetTime ;
var token = CancellationToken . None ;
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
var fakeLockingBackend = new FakeLockingBackendManager ();
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
async Task ApplyLocksForFilesetTimestamp ( DateTime filesetTimeLocal , CancellationToken ct )
{
var lockOptionsDict = new System . Collections . Generic . Dictionary < string , string? >(
backupOpts . ToDictionary ( kvp => kvp . Key , kvp => ( string? ) kvp . Value )
);
// Make sure the lock options are present with exact keys.
lockOptionsDict [ "remote-file-lock-duration" ] = LockDurationOptionValue ;
lockOptionsDict [ "dbpath" ] = DBFILE ;
var lockingOptions = new Options ( lockOptionsDict );
Assert . That ( lockingOptions . RemoteFileLockDuration , Is . Not . Null , "Expected remote-file-lock-duration to be set for lock operation" );
await using var lockDb = await LocalLockDatabase . CreateAsync ( DBFILE , null , ct ). ConfigureAwait ( false );
// Apply locks to all volumes associated with this fileset. Because the file:// backend
// does not implement ILockingBackend, we simulate the backend lock operation and
// only verify the database lock state.
var handler = new SetLocksHandler ( lockingOptions , new SetLockResults (), new [] { filesetTimeLocal });
await handler . RunAsync ( fakeLockingBackend , lockDb , new [] { filesetTimeLocal }). ConfigureAwait ( false );
}
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
var b1BeginTime = DateTime . MinValue ;
using ( var c1 = new Controller ( target , backupOpts , null ))
{
2026-05-13 15:04:13 +02:00
var b1 = await c1 . BackupAsync ( new [] { DATAFOLDER });
2025-12-17 22:27:38 +01:00
Assert . That ( b1 . Errors , Is . Empty , "Backup 1 had errors" );
b1BeginTime = b1 . BeginTime ;
}
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
// Capture the latest fileset (the one created by backup 1) and apply locks.
await using ( var db = await LocalDatabase . CreateLocalDatabaseAsync ( DBFILE , null , true , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
var filesets = await db . FilesetTimesAsync ( token )
2025-12-14 17:17:48 +01:00
. ToArrayAsync ( cancellationToken : token )
. ConfigureAwait ( false );
2025-12-17 22:27:38 +01:00
Assert . That ( filesets . Length , Is . GreaterThanOrEqualTo ( 1 ), "Expected at least 1 fileset after first backup" );
var latest = filesets . First ();
oldestFilesetId = latest . Key ;
oldestFilesetTime = latest . Value ;
}
await ApplyLocksForFilesetTimestamp ( oldestFilesetTime , token ). ConfigureAwait ( false );
// Ensure the second backup has a distinct fileset timestamp.
SleepUntilNextSecond ( b1BeginTime );
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
// Optional delay between backups to more clearly separate their lock windows.
await Task . Delay ( delayBetweenBackups ). ConfigureAwait ( false );
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
TestUtils . WriteTestFile ( file2 , 1024 );
using ( var c2 = new Controller ( target , backupOpts , null ))
{
2026-05-13 15:04:13 +02:00
var b2 = await c2 . BackupAsync ( new [] { DATAFOLDER });
2025-12-17 22:27:38 +01:00
Assert . That ( b2 . Errors , Is . Empty , "Backup 2 had errors" );
}
// Apply locks for the second backup as well.
await using ( var db = await LocalDatabase . CreateLocalDatabaseAsync ( DBFILE , null , true , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
var filesets = await db . FilesetTimesAsync ( token )
2025-12-14 17:17:48 +01:00
. ToArrayAsync ( cancellationToken : token )
. ConfigureAwait ( false );
2025-12-17 22:27:38 +01:00
Assert . That ( filesets . Length , Is . GreaterThanOrEqualTo ( 2 ), "Expected at least 2 filesets after second backup" );
var newest = filesets . First (). Value ;
await ApplyLocksForFilesetTimestamp ( newest , token ). ConfigureAwait ( false );
}
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
async Task < bool > IsFilesetLockedAsync ( long filesetId , CancellationToken ct )
{
await using var deleteDb = await LocalDeleteDatabase . CreateAsync ( DBFILE , "LockCheck" , null , ct ). ConfigureAwait ( false );
2026-05-13 15:04:13 +02:00
return await deleteDb . HasAnyLockedFilesAsync ( filesetId , DateTime . UtcNow , ct ). ConfigureAwait ( false );
2025-12-14 17:17:48 +01:00
}
2025-12-17 22:27:38 +01:00
// Verify that the oldest fileset has at least one active lock immediately after the backups.
Assert . That ( await IsFilesetLockedAsync ( oldestFilesetId , token ). ConfigureAwait ( false ),
Is . True ,
"Expected oldest fileset to have an active lock after backup when file-lock-duration is configured" );
// First delete attempt while the lock is still active should skip deleting the oldest fileset.
2025-12-14 17:17:48 +01:00
var deleteOpts = new System . Collections . Generic . Dictionary < string , string >( backupOpts )
{
["keep-versions"] = "1" ,
["no-auto-compact"] = "true" ,
};
using ( var cdelete = new Controller ( target , deleteOpts , null ))
{
2026-05-13 15:04:13 +02:00
var deleteResults = await cdelete . DeleteAsync ();
2025-12-17 22:27:38 +01:00
Assert . That ( deleteResults . Errors , Is . Empty , "Delete (with active lock) had errors" );
Assert . That ( deleteResults . DeletedSets , Is . Empty , "Expected no filesets to be deleted while lock is active" );
2025-12-14 17:17:48 +01:00
Assert . That ( deleteResults . Warnings . Any ( x => x . Contains ( "Skipping deletion of fileset version" , StringComparison . Ordinal )),
Is . True ,
2025-12-17 22:27:38 +01:00
"Expected warning about skipping fileset deletion due to active lock" );
}
2025-12-14 17:17:48 +01:00
2025-12-17 22:27:38 +01:00
// Wait until the lock for the oldest fileset has expired before attempting deletion again.
var startWait = DateTime . UtcNow ;
while ( await IsFilesetLockedAsync ( oldestFilesetId , token ). ConfigureAwait ( false ))
{
if ( DateTime . UtcNow - startWait > lockWaitTimeout )
Assert . Fail ( $"Timed out waiting for lock on fileset {oldestFilesetId} to expire" );
await Task . Delay ( lockPollInterval ). ConfigureAwait ( false );
}
// Second delete attempt after the lock has expired should now delete the oldest fileset.
var finalDeleteOpts = new System . Collections . Generic . Dictionary < string , string >( backupOpts )
{
["keep-versions"] = "1" ,
["no-auto-compact"] = "true" ,
};
using ( var cdelete = new Controller ( target , finalDeleteOpts , null ))
{
2026-05-13 15:04:13 +02:00
var deleteResults = await cdelete . DeleteAsync ();
2025-12-17 22:27:38 +01:00
Assert . That ( deleteResults . Errors , Is . Empty , "Delete (after lock expiration) had errors" );
var deletedSets = deleteResults . DeletedSets ?. ToArray () ?? Array . Empty < Tuple < long , DateTime >>();
Assert . That ( deletedSets . Length , Is . EqualTo ( 1 ), "Expected exactly one fileset to be deleted after lock expiration" );
Assert . That ( deletedSets [ 0 ]. Item2 , Is . EqualTo ( oldestFilesetTime ),
"Expected the deleted fileset to be the oldest (initial) version" );
2025-12-14 17:17:48 +01:00
}
}
[Test]
[Category("Compact")]
2026-05-13 15:04:13 +02:00
public async Task CompactDetectsAndAvoidsLockedCompactableVolumeAsync ()
2025-12-14 17:17:48 +01:00
{
var testopts = TestOptions ;
testopts [ "backup-test-samples" ] = "0" ;
testopts [ "number-of-retries" ] = "0" ;
testopts [ "dblock-size" ] = "20KB" ;
testopts [ "blocksize" ] = "1KB" ;
testopts [ "threshold" ] = "5" ;
testopts [ "no-auto-compact" ] = "true" ;
// Force compaction to be considered by making all volumes qualify as "small",
// and setting the max small-file count to 0.
testopts [ "small-file-size" ] = "1TB" ;
testopts [ "small-file-max-count" ] = "0" ;
var target = "file://" + TARGETFOLDER ;
var file1 = Path . Combine ( DATAFOLDER , "f1" );
var file2 = Path . Combine ( DATAFOLDER , "f2" );
var file3 = Path . Combine ( DATAFOLDER , "f3" );
var file4 = Path . Combine ( DATAFOLDER , "f4" );
var file5 = Path . Combine ( DATAFOLDER , "f5" );
var file6 = Path . Combine ( DATAFOLDER , "f6" );
const long fileSize = 45 * 1024 ;
using ( var c = new Controller ( target , testopts , null ))
{
// Create multiple versions and remove some files to generate compactable volumes.
TestUtils . WriteTestFile ( file1 , fileSize );
TestUtils . WriteTestFile ( file2 , fileSize );
2026-05-13 15:04:13 +02:00
var b1 = await c . BackupAsync ( new [] { DATAFOLDER });
2025-12-14 17:17:48 +01:00
Assert . That ( b1 . Errors , Is . Empty , "Backup 1 had errors" );
SleepUntilNextSecond ( b1 . BeginTime );
TestUtils . WriteTestFile ( file3 , fileSize );
TestUtils . WriteTestFile ( file4 , fileSize );
2026-05-13 15:04:13 +02:00
var b2 = await c . BackupAsync ( new [] { DATAFOLDER });
2025-12-14 17:17:48 +01:00
Assert . That ( b2 . Errors , Is . Empty , "Backup 2 had errors" );
SleepUntilNextSecond ( b2 . BeginTime );
TestUtils . WriteTestFile ( file5 , fileSize );
TestUtils . WriteTestFile ( file6 , fileSize );
2026-05-13 15:04:13 +02:00
var b3 = await c . BackupAsync ( new [] { DATAFOLDER });
2025-12-14 17:17:48 +01:00
Assert . That ( b3 . Errors , Is . Empty , "Backup 3 had errors" );
SleepUntilNextSecond ( b3 . BeginTime );
File . Delete ( file1 );
File . Delete ( file3 );
File . Delete ( file5 );
2026-05-13 15:04:13 +02:00
var b4 = await c . BackupAsync ( new [] { DATAFOLDER });
2025-12-14 17:17:48 +01:00
Assert . That ( b4 . Errors , Is . Empty , "Backup 4 had errors" );
var token = CancellationToken . None ;
var optionsObj = new Options ( testopts );
2026-01-06 10:22:53 +01:00
string? lockedCandidateName ;
2025-12-14 17:17:48 +01:00
await using ( var db = await LocalDeleteDatabase . CreateAsync ( DBFILE , "CompactLockTest" , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
var report = await db . GetCompactReportAsync ( optionsObj . VolumeSize , optionsObj . Threshold , optionsObj . SmallFileSize , optionsObj . SmallFileMaxCount , token ). ConfigureAwait ( false );
2025-12-14 17:17:48 +01:00
lockedCandidateName = report . CompactableVolumes . FirstOrDefault ();
}
if ( string . IsNullOrWhiteSpace ( lockedCandidateName ))
Assert . Inconclusive ( "No compactable volumes were found; cannot validate lock warning behavior." );
await using ( var lockDb = await LocalLockDatabase . CreateAsync ( DBFILE , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
await lockDb . UpdateRemoteVolumeLockExpirationAsync ( lockedCandidateName , DateTime . UtcNow . AddHours ( 1 ), token ). ConfigureAwait ( false );
2025-12-14 17:17:48 +01:00
await lockDb . Transaction . CommitAsync ( token : token ). ConfigureAwait ( false );
}
2026-05-13 15:04:13 +02:00
var compactResults = await c . CompactAsync ();
2025-12-14 17:17:48 +01:00
Assert . That ( compactResults . Errors , Is . Empty , "Compact had errors" );
2025-12-17 22:27:38 +01:00
Assert . That ( compactResults . Messages . Any ( x => x . Contains ( "selected for compaction but has an active lock" , StringComparison . Ordinal ) && x . Contains ( lockedCandidateName , StringComparison . Ordinal )),
2025-12-14 17:17:48 +01:00
Is . True ,
"Expected warning about compacting a locked volume" );
}
}
2026-04-04 14:37:31 +11:00
[Test]
[Category("Compact")]
2026-05-13 15:04:13 +02:00
public async Task CompactRestoresStoredWritePathOptionsWhenCliOmitsThemAsync ()
2026-04-04 14:37:31 +11:00
{
var backupOpts = TestOptions ;
backupOpts [ "prefix" ] = "compactprefix" ;
backupOpts [ "backup-test-samples" ] = "0" ;
backupOpts [ "number-of-retries" ] = "0" ;
backupOpts [ "dblock-size" ] = "20KB" ;
backupOpts [ "blocksize" ] = "1KB" ;
backupOpts [ "threshold" ] = "5" ;
backupOpts [ "no-auto-compact" ] = "true" ;
backupOpts [ "small-file-size" ] = "1TB" ;
backupOpts [ "small-file-max-count" ] = "0" ;
backupOpts [ "no-encryption" ] = "true" ;
var target = "file://" + TARGETFOLDER ;
var file1 = Path . Combine ( DATAFOLDER , "f1" );
var file2 = Path . Combine ( DATAFOLDER , "f2" );
var file3 = Path . Combine ( DATAFOLDER , "f3" );
var file4 = Path . Combine ( DATAFOLDER , "f4" );
var file5 = Path . Combine ( DATAFOLDER , "f5" );
var file6 = Path . Combine ( DATAFOLDER , "f6" );
const long fileSize = 45 * 1024 ;
using ( var c = new Controller ( target , backupOpts , null ))
{
TestUtils . WriteTestFile ( file1 , fileSize );
TestUtils . WriteTestFile ( file2 , fileSize );
2026-05-13 15:04:13 +02:00
var b1 = await c . BackupAsync ( new [] { DATAFOLDER });
2026-04-04 14:37:31 +11:00
Assert . That ( b1 . Errors , Is . Empty , "Backup 1 had errors" );
SleepUntilNextSecond ( b1 . BeginTime );
TestUtils . WriteTestFile ( file3 , fileSize );
TestUtils . WriteTestFile ( file4 , fileSize );
2026-05-13 15:04:13 +02:00
var b2 = await c . BackupAsync ( new [] { DATAFOLDER });
2026-04-04 14:37:31 +11:00
Assert . That ( b2 . Errors , Is . Empty , "Backup 2 had errors" );
SleepUntilNextSecond ( b2 . BeginTime );
TestUtils . WriteTestFile ( file5 , fileSize );
TestUtils . WriteTestFile ( file6 , fileSize );
2026-05-13 15:04:13 +02:00
var b3 = await c . BackupAsync ( new [] { DATAFOLDER });
2026-04-04 14:37:31 +11:00
Assert . That ( b3 . Errors , Is . Empty , "Backup 3 had errors" );
SleepUntilNextSecond ( b3 . BeginTime );
File . Delete ( file1 );
File . Delete ( file3 );
File . Delete ( file5 );
2026-05-13 15:04:13 +02:00
var b4 = await c . BackupAsync ( new [] { DATAFOLDER });
2026-04-04 14:37:31 +11:00
Assert . That ( b4 . Errors , Is . Empty , "Backup 4 had errors" );
}
var token = CancellationToken . None ;
await using ( var db = await LocalDatabase . CreateLocalDatabaseAsync ( DBFILE , null , true , null , token ). ConfigureAwait ( false ))
{
2026-05-13 15:04:13 +02:00
var storedOptions = await db . GetDbOptionsAsync ( token ). ConfigureAwait ( false );
2026-04-04 14:37:31 +11:00
Assert . That ( storedOptions [ "prefix" ], Is . EqualTo ( "compactprefix" ));
Assert . That ( storedOptions [ "dblock-size" ], Is . EqualTo (( 20 * 1024 ). ToString ()));
Assert . That ( storedOptions [ "compression-module" ], Is . EqualTo ( "zip" ));
Assert . That ( storedOptions [ "passphrase" ], Is . EqualTo ( "no-encryption" ));
}
var dblockFilesBeforeCompact = Directory . GetFiles ( TARGETFOLDER , "*.dblock.*" )
. Select ( Path . GetFileName )
. ToArray ();
var compactOpts = new System . Collections . Generic . Dictionary < string , string >
{
["dbpath"] = DBFILE ,
["backup-test-samples"] = "0" ,
["number-of-retries"] = "0" ,
["threshold"] = "5" ,
["no-auto-compact"] = "true" ,
["small-file-size"] = "1TB" ,
["small-file-max-count"] = "0"
};
using ( var c = new Controller ( target , compactOpts , null ))
{
2026-05-13 15:04:13 +02:00
var compactResults = await c . CompactAsync ();
2026-04-04 14:37:31 +11:00
Assert . That ( compactResults . Errors , Is . Empty , "Compact had errors" );
Assert . That ( compactResults . DownloadedFileCount , Is . GreaterThan ( 0 ), "No compact operation was performed" );
}
var newDblockFiles = Directory . GetFiles ( TARGETFOLDER , "*.dblock.*" )
. Select ( Path . GetFileName )
. Except ( dblockFilesBeforeCompact , StringComparer . Ordinal )
. ToArray ();
Assert . That ( newDblockFiles . Length , Is . GreaterThan ( 1 ), "Expected compact to create multiple dblock files using the stored 20KB dblock-size" );
Assert . That ( newDblockFiles . All ( name => name is not null && name . StartsWith ( "compactprefix-" , StringComparison . Ordinal ) && name . EndsWith ( ".dblock.zip" , StringComparison . Ordinal )), Is . True ,
$"Expected compact to preserve the stored unencrypted zip write-path, got: {string.Join(" , ", newDblockFiles)}" );
}
2025-12-14 17:17:48 +01:00
}
}