Files
duplicati/Duplicati/UnitTest/SetLocksHandlerTests.cs
2026-04-16 15:21:24 +02:00

253 lines
12 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.
#nullable enable
using System;
using System.Collections.Generic;
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.SQLiteHelper;
using Duplicati.Library.Utility;
using IndexVolumeWriter = Duplicati.Library.Main.Volumes.IndexVolumeWriter;
using IRemoteVolume = Duplicati.Library.Main.Database.IRemoteVolume;
using VolumeWriterBase = Duplicati.Library.Main.Volumes.VolumeWriterBase;
using InterfaceFileEntry = Duplicati.Library.Interface.IFileEntry;
using NUnit.Framework;
using NUnit.Framework.Legacy;
namespace Duplicati.UnitTest
{
[TestFixture]
public class SetLocksHandlerTests : BasicSetupHelper
{
private sealed class FakeLockingBackendManager : IBackendManager
{
private readonly bool m_failFirstLock;
private bool m_hasFailed;
public FakeLockingBackendManager(bool failFirstLock = false)
{
m_failFirstLock = failFirstLock;
}
public List<(string Name, DateTime UntilUtc)> LockedVolumes { get; } = new();
public bool WaitedForEmpty { get; private set; }
public bool SupportsObjectLocking => true;
public Task SetObjectLockUntilAsync(string remotename, DateTime lockUntilUtc, CancellationToken cancelToken)
{
LockedVolumes.Add((remotename, lockUntilUtc));
if (m_failFirstLock && !m_hasFailed)
{
m_hasFailed = true;
throw new InvalidOperationException("Simulated lock failure");
}
return Task.CompletedTask;
}
public Task<DateTime?> GetObjectLockUntilAsync(string remotename, CancellationToken cancelToken)
=> Task.FromResult<DateTime?>(LockedVolumes.FirstOrDefault(x => x.Name == remotename).UntilUtc);
public Task WaitForEmptyAsync(CancellationToken cancellationToken)
{
WaitedForEmpty = true;
return Task.CompletedTask;
}
public Task WaitForEmptyAsync(LocalDatabase database, CancellationToken cancellationToken) => WaitForEmptyAsync(cancellationToken);
public void Dispose() { }
#region Unused interface members
public Task PutAsync(VolumeWriterBase blockVolume, 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<IEnumerable<InterfaceFileEntry>> ListAsync(CancellationToken cancelToken) => throw new NotImplementedException();
public TempFile DecryptFile(TempFile volume, string volume_name, Options options) => 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 IAsyncEnumerable<(TempFile File, string Hash, long Size, string Name)> GetFilesOverlappedAsync(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
}
[SetUp]
public void SetUp()
{
File.WriteAllText(Path.Combine(DATAFOLDER, "file.txt"), "content");
}
[Test]
[Category("LockHandler")]
public async Task AppliesLocksForVolumesInFileset()
{
var options = new Dictionary<string, string>(TestOptions);
using (var controller = new Controller("file://" + TARGETFOLDER, options, null))
{
controller.Backup([DATAFOLDER]);
}
var lockDbPath = Path.Combine(BASEFOLDER, $"locktest-{Guid.NewGuid():N}.sqlite");
File.Copy(options["dbpath"], lockDbPath, true);
await using var db = await LocalLockDatabase.CreateAsync(lockDbPath, null, CancellationToken.None).ConfigureAwait(false);
var filesets = new List<KeyValuePair<long, DateTime>>();
await foreach (var entry in db.FilesetTimes(CancellationToken.None).ConfigureAwait(false))
filesets.Add(entry);
var targetFileset = filesets.Last();
var expectedVolumes = new List<string>();
await foreach ((var volume, _) in db.GetRemoteVolumesDependingOnFilesets(new[] { targetFileset.Key }, CancellationToken.None).ConfigureAwait(false))
expectedVolumes.Add(volume);
var lockingOptions = new Options(new Dictionary<string, string?>(options.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value))
{
["dbpath"] = lockDbPath,
["remote-file-lock-duration"] = "1D",
});
var backend = new FakeLockingBackendManager();
var handler = new SetLocksHandler(lockingOptions, new SetLockResults(), new[] { targetFileset.Value });
await handler.RunAsync(backend, db).ConfigureAwait(false);
CollectionAssert.AreEquivalent(expectedVolumes, backend.LockedVolumes.Select(x => x.Name));
ClassicAssert.True(backend.WaitedForEmpty);
}
[Test]
[Category("LockHandler")]
public async Task ContinuesWhenLockingFails()
{
var options = new Dictionary<string, string>(TestOptions);
using (var controller = new Controller("file://" + TARGETFOLDER, options, null))
{
controller.Backup([DATAFOLDER]);
}
var lockDbPath = Path.Combine(BASEFOLDER, $"locktest-{Guid.NewGuid():N}.sqlite");
File.Copy(options["dbpath"], lockDbPath, true);
await using var db = await LocalLockDatabase.CreateAsync(lockDbPath, null, CancellationToken.None).ConfigureAwait(false);
var filesets = new List<KeyValuePair<long, DateTime>>();
await foreach (var entry in db.FilesetTimes(CancellationToken.None).ConfigureAwait(false))
filesets.Add(entry);
var latestFileset = filesets.Last();
var expectedVolumes = new List<string>();
await foreach ((var volume, _) in db.GetRemoteVolumesDependingOnFilesets(new[] { latestFileset.Key }, CancellationToken.None).ConfigureAwait(false))
expectedVolumes.Add(volume);
var lockingOptions = new Options(new Dictionary<string, string?>(options.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value))
{
["dbpath"] = lockDbPath,
["remote-file-lock-duration"] = "1D",
});
var backend = new FakeLockingBackendManager(failFirstLock: true);
var handler = new SetLocksHandler(lockingOptions, new SetLockResults(), new[] { latestFileset.Value });
Assert.DoesNotThrowAsync(() => handler.RunAsync(backend, db));
CollectionAssert.AreEquivalent(expectedVolumes, backend.LockedVolumes.Select(x => x.Name));
ClassicAssert.True(backend.WaitedForEmpty);
}
/// <summary>
/// Tests that GetRemoteVolumesDependingOnFilesets works correctly with a large number of fileset IDs
/// that triggers the temporary table code path (when count > CHUNK_SIZE = 128).
/// </summary>
[Test]
[Category("Database")]
public async Task GetRemoteVolumesDependingOnFilesets_WithLargeInput_UsesTemporaryTable()
{
using var dbfile = new TempFile();
using var db = SQLiteLoader.LoadConnection(dbfile);
// Use DatabaseUpgrader to create the schema from embedded resources
DatabaseUpgrader.UpgradeDatabase(db, dbfile, typeof(DatabaseSchemaMarker));
using var cmd = db.CreateCommand();
// Insert an operation record (required for LocalDatabase initialization)
cmd.CommandText = @"INSERT INTO ""Operation"" (""Description"", ""Timestamp"") VALUES ('Test', 0)";
cmd.ExecuteNonQuery();
// Create 150 fileset IDs (exceeds CHUNK_SIZE of 128) to trigger temporary table path
var filesetIds = new List<long>();
for (int i = 0; i < 150; i++)
{
// Insert a remote volume for each fileset
cmd.CommandText = $@"
INSERT INTO ""Remotevolume"" (""ID"", ""OperationID"", ""Name"", ""Type"", ""State"", ""VerificationCount"", ""DeleteGraceTime"", ""ArchiveTime"", ""LockExpirationTime"")
VALUES ({i + 1}, 1, 'volume-{i}.zip', 'Files', 'Verified', 0, 0, 0, 0)";
cmd.ExecuteNonQuery();
// Insert a fileset referencing this volume
cmd.CommandText = $@"
INSERT INTO ""Fileset"" (""ID"", ""OperationID"", ""VolumeID"", ""IsFullBackup"", ""Timestamp"")
VALUES ({i + 1}, 1, {i + 1}, 1, {i})";
cmd.ExecuteNonQuery();
filesetIds.Add(i + 1);
}
// Close the connection so LocalDatabase can open it
db.Close();
// Create LocalDatabase instance
await using var localDb = await LocalDatabase.CreateLocalDatabaseAsync(
dbfile,
"TestOperation",
true,
null,
CancellationToken.None
);
// Act: Call GetRemoteVolumesDependingOnFilesets with 150 fileset IDs
// This should trigger the temporary table code path
var volumes = new List<string>();
await foreach ((var volume, _) in localDb.GetRemoteVolumesDependingOnFilesets(filesetIds, CancellationToken.None).ConfigureAwait(false))
volumes.Add(volume);
// Assert: Should return all 150 volumes
Assert.That(volumes.Count, Is.EqualTo(150));
for (int i = 0; i < 150; i++)
Assert.That(volumes, Does.Contain($"volume-{i}.zip"));
}
}
}