Files
duplicati/Duplicati/UnitTest/Issue1570.cs
T

239 lines
11 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Duplicati.Library.Main;
using NUnit.Framework;
using Utility = Duplicati.Library.Utility.Utility;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
using Duplicati.Library.Main.Database.Local;
namespace Duplicati.UnitTest
{
/// <summary>
/// Reproduction test for issue #1570.
///
/// The issue is that during the first backup, if the process is interrupted
/// before the dlist (fileset) is uploaded, resuming the backup should not
/// re-upload files that were already successfully processed. The local database
/// should retain knowledge of uploaded blocks so the resumed backup can continue
/// efficiently without treating already-processed files as entirely new.
///
/// See: https://github.com/duplicati/duplicati/issues/1570
/// </summary>
public class Issue1570 : BasicSetupHelper
{
// Files to create in MB.
private readonly int[] fileSizes = { 5, 10, 15 };
/// <summary>
/// Creates test source files.
/// </summary>
private void CreateSourceFiles()
{
foreach (int size in this.fileSizes)
{
byte[] data = new byte[size * 1024 * 1024];
Random rng = new Random(size); // deterministic seed for reproducibility
rng.NextBytes(data);
File.WriteAllBytes(Path.Combine(this.DATAFOLDER, size + "MB"), data);
}
}
/// <summary>
/// Returns the combined size of all dblock files in the target folder.
/// </summary>
private long GetDblockTotalSize()
{
return Directory.EnumerateFiles(this.TARGETFOLDER, "*.dblock.*")
.Select(f => new FileInfo(f).Length)
.Sum();
}
/// <summary>
/// Returns the number of dlist files in the target folder.
/// </summary>
private int GetDlistCount()
{
return Directory.EnumerateFiles(this.TARGETFOLDER, "*.dlist.*").Count();
}
[Test]
[Category("Disruption")]
[Category("Bug")]
public async Task FirstBackupInterruptedAndResumedAsync()
{
// Use a small dblock size so multiple volumes are created.
Dictionary<string, string> options = new Dictionary<string, string>(this.TestOptions)
{
["dblock-size"] = "5mb",
["no-encryption"] = "true",
["number-of-retries"] = "0",
};
this.CreateSourceFiles();
// --- Step 1: Run a first backup that fails during dlist upload ---
// Register the deterministic error backend
Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend());
var failtarget = new DeterministicErrorBackend().ProtocolKey + "://" + this.TARGETFOLDER;
var failed = false;
// Fail when uploading the dlist file, but allow all dblock uploads
DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) =>
{
if (action == DeterministicErrorBackend.BackendAction.PutBefore && remotename.Contains(".dlist."))
{
failed = true;
return true;
}
return false;
};
using (var c = new Controller(failtarget, options, null))
{
var ex = Assert.CatchAsync(async () => await c.BackupAsync(new[] { this.DATAFOLDER }));
Assert.That(ex, Is.Not.Null, "Backup should have thrown an exception");
}
Assert.That(failed, Is.True, "Failed to trigger the dlist upload failure");
// Wait a moment to ensure the resumed backup's operation timestamp is
// strictly after the synthetic filelist timestamp created from the
// interrupted backup. Fileset filenames have one-second resolution, so
// a sub-second gap between backups can otherwise place the synthetic
// fileset timestamp ahead of the current operation timestamp.
await Task.Delay(TimeSpan.FromSeconds(1));
// Verify that dblock files were uploaded but no dlist exists
var dblockFilesAfterFail = Directory.EnumerateFiles(this.TARGETFOLDER, "*.dblock.*").ToList();
Assert.That(dblockFilesAfterFail.Count, Is.GreaterThan(0), "At least one dblock file should have been uploaded");
long dblockSizeAfterFail = this.GetDblockTotalSize();
TestContext.WriteLine("Dblock files after failed dlist: {0}, total size: {1}", dblockFilesAfterFail.Count, dblockSizeAfterFail);
// The total source data size (5 + 10 + 15 = 30 MB).
var totalSourceSize = this.fileSizes.Sum() * 1024L * 1024L;
// After the dlist failure there should be a meaningful amount of data on the remote.
Assert.That(dblockSizeAfterFail, Is.GreaterThan(totalSourceSize / 4),
"A significant portion of the source data should have been uploaded before the dlist failure");
var dlistCountAfterFail = this.GetDlistCount();
TestContext.WriteLine("Dlist count after failed dlist: {0}", dlistCountAfterFail);
// --- Step 2: Resume the first backup without modifying source files ---
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
var resumeResults = await c.BackupAsync(new[] { this.DATAFOLDER });
Assert.AreEqual(0, resumeResults.Errors.Count(), "Resumed backup should not have errors");
Assert.That(resumeResults.PartialBackup, Is.False, "Resumed backup should be a full backup");
// All files should be examined
Assert.That(resumeResults.ExaminedFiles, Is.EqualTo(this.fileSizes.Length),
"All source files should be examined during the resumed backup");
}
// Verify that the total dblock size did not grow excessively.
// This is the key check for issue #1570: data already uploaded during
// the failed attempt should not be duplicated on resume.
var dblockSizeAfterResume = this.GetDblockTotalSize();
TestContext.WriteLine("Dblock total size after failed dlist: {0}", dblockSizeAfterFail);
TestContext.WriteLine("Dblock total size after resume: {0}", dblockSizeAfterResume);
TestContext.WriteLine("Dblock size increase: {0}", dblockSizeAfterResume - dblockSizeAfterFail);
// If Duplicati correctly re-uses already-uploaded blocks, the dblock
// size increase should be very small (metadata overhead only).
// If it re-uploads everything, the increase would be ~totalSourceSize.
var sizeIncrease = dblockSizeAfterResume - dblockSizeAfterFail;
Assert.That(sizeIncrease, Is.LessThan(totalSourceSize / 4),
"Dblock size should not increase significantly on resume. " +
"An increase close to the source data size indicates that already-uploaded blocks were duplicated. " +
"This is the core concern raised in issue #1570.");
// Sanity check: overall dblock size should still be reasonable
Assert.That(dblockSizeAfterResume, Is.LessThan(totalSourceSize * 2),
"Total dblock size after resume should be within reasonable bounds.");
// --- Step 3: Verify the backup is complete and restorable ---
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
var filesets = (await c.ListAsync()).Filesets.ToList();
Assert.That(filesets.Count, Is.GreaterThanOrEqualTo(1), "There should be at least one completed fileset");
// At least one fileset should be a full backup
Assert.That(filesets.Any(f => f.IsFullBackup == BackupType.FULL_BACKUP), Is.True,
"There should be at least one full backup");
var listResults = await c.ListAsync("*");
var backedUpFiles = listResults.Files
.Select(x => x.Path)
.Where(x => !Utility.IsFolder(x, File.GetAttributes))
.ToArray();
Assert.That(backedUpFiles.Length, Is.EqualTo(this.fileSizes.Length),
"All source files should be present in the backup");
}
// --- Step 4: Restore and verify all files ---
var restoreOptions = new Dictionary<string, string>(options)
{
["restore-path"] = this.RESTOREFOLDER
};
using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null))
{
var listResults = await c.ListAsync("*");
var allFiles = listResults.Files
.Select(x => x.Path)
.Where(x => !Utility.IsFolder(x, File.GetAttributes))
.ToArray();
var restoreResults = await c.RestoreAsync(allFiles);
Assert.That(restoreResults.RestoredFiles, Is.EqualTo(this.fileSizes.Length),
"All files should be restored");
foreach (string filepath in allFiles)
{
string filename = Path.GetFileName(filepath);
string originalPath = filepath; // path inside DATAFOLDER
string restoredPath = Path.Combine(this.RESTOREFOLDER, filename ?? string.Empty);
TestUtils.AssertFilesAreEqual(originalPath, restoredPath, false, filename);
}
}
// --- Step 5: Verify database can be recreated from remote ---
File.Delete(this.DBFILE);
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
TestUtils.AssertResults(await c.RepairAsync());
var listResults = await c.ListAsync();
Assert.That(listResults.Filesets.Count(), Is.GreaterThanOrEqualTo(1),
"Recreated database should have at least one fileset");
}
}
}
}