Files
duplicati/Duplicati/UnitTest/LockingDeleteAndCompactTests.cs
T
Kenneth Skovhede f03cf2f7a3 Detatch options on controller
When creating a new controller, the options dictionary was used by-reference.

This meant that code could update the options dictionary *after* creating the controller, which would not be thread-safe.

It does not have much effect on regular operations as the normal method is to create the dictionary, pass it, and not modify it.

But in the tests, we frequently re-use the dictionary and update it with new values to test various cases.

Due to the dictionary being a reference, the controller would update the dictionary to fit the operations, but this would reflect back into the tests, meaning that some tests were being run with options that were not supposed to be there.

This meant that at least two edge cases that we test for were never found to have errors, even though they do.

The first issue is the listing of files using a non-default blocksize. The second issue is recreating the database with a non-default file-hash algorithm.

This PR removes the reference passing, and fixes the issues that were uncovered.
2026-05-14 17:59:49 +02:00

488 lines
25 KiB
C#

// Copyright (C) 2026, 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 System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using Duplicati.Library.Main.Database;
using Duplicati.Library.Main.Operation;
using Duplicati.Library.Utility;
using NUnit.Framework;
#nullable enable
namespace Duplicati.UnitTest
{
public class LockingDeleteAndCompactTests : BasicSetupHelper
{
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();
public TempFile DecryptFile(TempFile volume, string volume_name, Options options, bool dispose) => throw new NotImplementedException();
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();
public System.Collections.Generic.IAsyncEnumerable<(TempFile File, string Name)> GetFilesOverlappedDirectAsync(System.Collections.Generic.IEnumerable<IRemoteVolume> volumes, CancellationToken cancelToken) => throw new NotImplementedException();
public Task FlushPendingMessagesAsync(LocalDatabase database, CancellationToken cancellationToken) => Task.CompletedTask;
public void UpdateThrottleValues(long maxUploadPrSecond, long maxDownloadPrSecond) => throw new NotImplementedException();
#endregion
}
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")]
public async Task DeleteSkipsLockedRemoteFilesetVolumeAsync()
{
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))
{
var b1 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b1.Errors, Is.Empty, "Backup 1 had errors");
SleepUntilNextSecond(b1.BeginTime);
TestUtils.WriteTestFile(file2, 1024);
var b2 = await c.BackupAsync(new[] { DATAFOLDER });
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))
{
var filesets = await db.FilesetTimesAsync(token)
.ToArrayAsync(cancellationToken: token)
.ConfigureAwait(false);
Assert.That(filesets.Length, Is.GreaterThanOrEqualTo(2), "Expected at least 2 filesets");
var oldestFilesetId = filesets.Last().Key;
var oldestFilesetVolume = await db.GetRemoteVolumeFromFilesetIDAsync(oldestFilesetId, token).ConfigureAwait(false);
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))
{
await lockDb.UpdateRemoteVolumeLockExpirationAsync(oldestFilesetVolumeName, DateTime.UtcNow.AddHours(1), token).ConfigureAwait(false);
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))
{
var deleteResults = await cdelete.DeleteAsync();
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")]
public async Task DeleteHonorsFilesetLocksFromBackupUntilExpirationAsync()
{
// 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";
var target = "file://" + TARGETFOLDER;
var file1 = Path.Combine(DATAFOLDER, "f1");
var file2 = Path.Combine(DATAFOLDER, "f2");
TestUtils.WriteTestFile(file1, 1024);
long oldestFilesetId;
DateTime oldestFilesetTime;
var token = CancellationToken.None;
var fakeLockingBackend = new FakeLockingBackendManager();
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);
}
var b1BeginTime = DateTime.MinValue;
using (var c1 = new Controller(target, backupOpts, null))
{
var b1 = await c1.BackupAsync(new[] { DATAFOLDER });
Assert.That(b1.Errors, Is.Empty, "Backup 1 had errors");
b1BeginTime = b1.BeginTime;
}
// 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))
{
var filesets = await db.FilesetTimesAsync(token)
.ToArrayAsync(cancellationToken: token)
.ConfigureAwait(false);
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);
// Optional delay between backups to more clearly separate their lock windows.
await Task.Delay(delayBetweenBackups).ConfigureAwait(false);
TestUtils.WriteTestFile(file2, 1024);
using (var c2 = new Controller(target, backupOpts, null))
{
var b2 = await c2.BackupAsync(new[] { DATAFOLDER });
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))
{
var filesets = await db.FilesetTimesAsync(token)
.ToArrayAsync(cancellationToken: token)
.ConfigureAwait(false);
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);
}
async Task<bool> IsFilesetLockedAsync(long filesetId, CancellationToken ct)
{
await using var deleteDb = await LocalDeleteDatabase.CreateAsync(DBFILE, "LockCheck", null, ct).ConfigureAwait(false);
return await deleteDb.HasAnyLockedFilesAsync(filesetId, DateTime.UtcNow, ct).ConfigureAwait(false);
}
// 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.
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))
{
var deleteResults = await cdelete.DeleteAsync();
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");
Assert.That(deleteResults.Warnings.Any(x => x.Contains("Skipping deletion of fileset version", StringComparison.Ordinal)),
Is.True,
"Expected warning about skipping fileset deletion due to active lock");
}
// 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))
{
var deleteResults = await cdelete.DeleteAsync();
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");
}
}
[Test]
[Category("Compact")]
public async Task CompactDetectsAndAvoidsLockedCompactableVolumeAsync()
{
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);
var b1 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b1.Errors, Is.Empty, "Backup 1 had errors");
SleepUntilNextSecond(b1.BeginTime);
TestUtils.WriteTestFile(file3, fileSize);
TestUtils.WriteTestFile(file4, fileSize);
var b2 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b2.Errors, Is.Empty, "Backup 2 had errors");
SleepUntilNextSecond(b2.BeginTime);
TestUtils.WriteTestFile(file5, fileSize);
TestUtils.WriteTestFile(file6, fileSize);
var b3 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b3.Errors, Is.Empty, "Backup 3 had errors");
SleepUntilNextSecond(b3.BeginTime);
File.Delete(file1);
File.Delete(file3);
File.Delete(file5);
var b4 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b4.Errors, Is.Empty, "Backup 4 had errors");
var token = CancellationToken.None;
var optionsObj = new Options(testopts);
string? lockedCandidateName;
await using (var db = await LocalDeleteDatabase.CreateAsync(DBFILE, "CompactLockTest", null, token).ConfigureAwait(false))
{
var report = await db.GetCompactReportAsync(optionsObj.VolumeSize, optionsObj.Threshold, optionsObj.SmallFileSize, optionsObj.SmallFileMaxCount, token).ConfigureAwait(false);
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))
{
await lockDb.UpdateRemoteVolumeLockExpirationAsync(lockedCandidateName, DateTime.UtcNow.AddHours(1), token).ConfigureAwait(false);
await lockDb.Transaction.CommitAsync(token: token).ConfigureAwait(false);
}
var compactResults = await c.CompactAsync();
Assert.That(compactResults.Errors, Is.Empty, "Compact had errors");
Assert.That(compactResults.Messages.Any(x => x.Contains("selected for compaction but has an active lock", StringComparison.Ordinal) && x.Contains(lockedCandidateName, StringComparison.Ordinal)),
Is.True,
"Expected warning about compacting a locked volume");
}
}
[Test]
[Category("Compact")]
public async Task CompactRestoresStoredWritePathOptionsWhenCliOmitsThemAsync()
{
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);
var b1 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b1.Errors, Is.Empty, "Backup 1 had errors");
SleepUntilNextSecond(b1.BeginTime);
TestUtils.WriteTestFile(file3, fileSize);
TestUtils.WriteTestFile(file4, fileSize);
var b2 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b2.Errors, Is.Empty, "Backup 2 had errors");
SleepUntilNextSecond(b2.BeginTime);
TestUtils.WriteTestFile(file5, fileSize);
TestUtils.WriteTestFile(file6, fileSize);
var b3 = await c.BackupAsync(new[] { DATAFOLDER });
Assert.That(b3.Errors, Is.Empty, "Backup 3 had errors");
SleepUntilNextSecond(b3.BeginTime);
File.Delete(file1);
File.Delete(file3);
File.Delete(file5);
var b4 = await c.BackupAsync(new[] { DATAFOLDER });
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))
{
var storedOptions = await db.GetDbOptionsAsync(token).ConfigureAwait(false);
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))
{
var compactResults = await c.CompactAsync();
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)}");
}
}
}