// Copyright (C) 2025, 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.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; using Microsoft.Data.Sqlite; namespace Duplicati.Library.Main.Database { /// /// A local database for deleting filesets and blocks. /// internal class LocalDeleteDatabase : LocalDatabase { /// /// The tag used for logging. /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// Flag for toggling temporary tables; set to empty string if debugging. /// private const string TEMPORARY = "TEMPORARY"; /// /// SQL command to register a duplicate block. /// private const string REGISTER_COMMAND = @" INSERT OR IGNORE INTO ""DuplicateBlock"" ( ""BlockID"", ""VolumeID"" ) SELECT ""ID"", @VolumeId FROM ""Block"" WHERE ""Hash"" = @Hash AND ""Size"" = @Size "; /// /// The command to register a duplicate block. /// private SqliteCommand m_registerDuplicateBlockCommand = null!; /// /// Creates a new instance of the class. /// /// The path to the database file. /// The operation name. /// An optional existing database instance to use. Used to mimic constructor chaining. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(string path, string operation, LocalDeleteDatabase? dbnew, CancellationToken token) { dbnew ??= new LocalDeleteDatabase(); dbnew = (LocalDeleteDatabase) await CreateLocalDatabaseAsync(path, operation, true, dbnew, token) .ConfigureAwait(false); dbnew.m_registerDuplicateBlockCommand = await dbnew.Connection.CreateCommandAsync(REGISTER_COMMAND, token) .ConfigureAwait(false); return dbnew; } /// /// Creates a new instance of the class with a parent database. /// /// The parent database to use. /// An optional existing database instance to use. Used to mimic constructor chaining. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(LocalDatabase dbparent, LocalDeleteDatabase? dbnew, CancellationToken token) { dbnew ??= new LocalDeleteDatabase(); dbnew = (LocalDeleteDatabase) await CreateLocalDatabaseAsync(dbparent, dbnew, token) .ConfigureAwait(false); dbnew.m_registerDuplicateBlockCommand = await dbnew.Connection.CreateCommandAsync(REGISTER_COMMAND, token) .ConfigureAwait(false); return dbnew; } /// /// Drops all entries related to operations listed in the table. /// /// The fileset entries to delete. /// A cancellation token to cancel the operation. /// An async enumerable of key-value pairs, where the key is the fileset name and the value is the size of the fileset. public async IAsyncEnumerable> DropFilesetsFromTable(DateTime[] toDelete, [EnumeratorCancellation] CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); var deleted = 0; await using (var tempTable = await TemporaryDbValueList.CreateAsync(this, toDelete.Select(Library.Utility.Utility.NormalizeDateTimeToEpochSeconds), token).ConfigureAwait(false)) deleted += await ( await cmd.SetCommandAndParameters(@" DELETE FROM ""Fileset"" WHERE ""Timestamp"" IN (@Timestamps) ") .ExpandInClauseParameterMssqliteAsync("@Timestamps", tempTable, token) .ConfigureAwait(false) ) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); if (deleted != toDelete.Length) throw new Exception($"Unexpected number of deleted filesets {deleted} vs {toDelete.Length}"); //Then we delete anything that is no longer being referenced await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""FilesetEntry"" WHERE ""FilesetID"" NOT IN ( SELECT DISTINCT ""ID"" FROM ""Fileset"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""ChangeJournalData"" WHERE ""FilesetID"" NOT IN ( SELECT DISTINCT ""ID"" FROM ""Fileset"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""FileLookup"" WHERE ""ID"" NOT IN ( SELECT DISTINCT ""FileID"" FROM ""FilesetEntry"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""Metadataset"" WHERE ""ID"" NOT IN ( SELECT DISTINCT ""MetadataID"" FROM ""FileLookup"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""Blockset"" WHERE ""ID"" NOT IN ( SELECT DISTINCT ""BlocksetID"" FROM ""FileLookup"" UNION SELECT DISTINCT ""BlocksetID"" FROM ""Metadataset"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""BlocksetEntry"" WHERE ""BlocksetID"" NOT IN ( SELECT DISTINCT ""ID"" FROM ""Blockset"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""BlocklistHash"" WHERE ""BlocksetID"" NOT IN ( SELECT DISTINCT ""ID"" FROM ""Blockset"" ) ", token) .ConfigureAwait(false); //We save the block info for the remote files, before we delete it await cmd.ExecuteNonQueryAsync(@" INSERT INTO ""DeletedBlock"" ( ""Hash"", ""Size"", ""VolumeID"" ) SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""Block"" WHERE ""ID"" NOT IN ( SELECT DISTINCT ""BlockID"" AS ""BlockID"" FROM ""BlocksetEntry"" UNION SELECT DISTINCT ""ID"" FROM ""Block"", ""BlocklistHash"" WHERE ""Block"".""Hash"" = ""BlocklistHash"".""Hash"" ) ", token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(@" DELETE FROM ""Block"" WHERE ""ID"" NOT IN ( SELECT DISTINCT ""BlockID"" FROM ""BlocksetEntry"" UNION SELECT DISTINCT ""ID"" FROM ""Block"", ""BlocklistHash"" WHERE ""Block"".""Hash"" = ""BlocklistHash"".""Hash"" ) ", token) .ConfigureAwait(false); //Find all remote filesets that are no longer required, and mark them as deleting var updated = await cmd.SetCommandAndParameters(@" UPDATE ""RemoteVolume"" SET ""State"" = @NewState WHERE ""Type"" = @CurrentType AND ""State"" IN (@AllowedStates) AND ""ID"" NOT IN ( SELECT ""VolumeID"" FROM ""Fileset"" ) ") .SetParameterValue("@NewState", RemoteVolumeState.Deleting.ToString()) .SetParameterValue("@CurrentType", RemoteVolumeType.Files.ToString()) .ExpandInClauseParameterMssqlite("@AllowedStates", [ RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString(), RemoteVolumeState.Temporary.ToString(), RemoteVolumeState.Deleting.ToString() ]) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); if (deleted != updated) throw new Exception($"Unexpected number of remote volumes marked as deleted. Found {deleted} filesets, but {updated} volumes"); cmd.SetCommandAndParameters(@" SELECT ""Name"", ""Size"" FROM ""RemoteVolume"" WHERE ""Type"" = @Type AND ""State"" = @State ") .SetParameterValue("@Type", RemoteVolumeType.Files.ToString()) .SetParameterValue("@State", RemoteVolumeState.Deleting.ToString()); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) yield return new KeyValuePair( rd.ConvertValueToString(0) ?? "", rd.ConvertValueToInt64(1) ); } /// /// Returns a collection of IListResultFilesets, where the Version is the backup version number /// exposed to the user. This is in contrast to other cases where the Version is the ID in the /// Fileset table. /// /// A cancellation token to cancel the operation. /// An async enumerable of IListResultFileset. internal async IAsyncEnumerable FilesetsWithBackupVersion([EnumeratorCancellation] CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); // TODO check if this is still the case? (shouldn't be with new sqlite driver): // We can also use the ROW_NUMBER() window function to generate the backup versions, // but this requires at least SQLite 3.25, which is not available in some common // distributions (e.g., Debian) currently. cmd.SetCommandAndParameters(@" SELECT ""IsFullBackup"", ""Timestamp"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC "); await using var reader = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); int version = 0; while (await reader.ReadAsync(token).ConfigureAwait(false)) { yield return new ListResultFileset( version++, reader.GetInt32(0), ParseFromEpochSeconds(reader.ConvertValueToInt64(1)).ToLocalTime(), -1L, -1L ); } } /// /// Represents the usage of a volume in the database. /// private struct VolumeUsage { /// /// The name of the volume. /// public readonly string Name; /// /// The size of the data stored in the volume. /// public readonly long DataSize; /// /// The size of the data that is no longer needed in the volume. /// public readonly long WastedSize; /// /// The size of the data that is compressed in the volume. /// public readonly long CompressedSize; /// /// Initializes a new instance of the struct. /// /// The name of the volume. /// The size of the data stored in the volume. /// The size of the data that is no longer needed in the volume. /// The size of the data that is compressed in the volume. public VolumeUsage(string name, long datasize, long wastedsize, long compressedsize) { Name = name; DataSize = datasize; WastedSize = wastedsize; CompressedSize = compressedsize; } } /// /// Returns the number of bytes stored in each volume, /// and the number of bytes no longer needed in each volume. /// The sizes are the uncompressed values. /// /// A cancellation token to cancel the operation. /// /// An asynchronous enumerable of , holding the name of the volume, /// the size of the data stored in the volume, the size of the data that is no longer needed in the volume, /// and the size of the data that is compressed in the volume. /// private async IAsyncEnumerable GetWastedSpaceReport([EnumeratorCancellation] CancellationToken token) { var tmptablename = $"UsageReport-{Library.Utility.Utility.GetHexGuid()}"; var usedBlocks = @" SELECT SUM(""Block"".""Size"") AS ""ActiveSize"", ""Block"".""VolumeID"" AS ""VolumeID"" FROM ""Block"", ""Remotevolume"" WHERE ""Block"".""VolumeID"" = ""Remotevolume"".""ID"" AND ""Block"".""ID"" NOT IN ( SELECT ""Block"".""ID"" FROM ""Block"", ""DeletedBlock"" WHERE ""Block"".""Hash"" = ""DeletedBlock"".""Hash"" AND ""Block"".""Size"" = ""DeletedBlock"".""Size"" AND ""Block"".""VolumeID"" = ""DeletedBlock"".""VolumeID"" ) GROUP BY ""Block"".""VolumeID"" "; var lastmodifiedFile = @" SELECT ""Block"".""VolumeID"" AS ""VolumeID"", ""Fileset"".""Timestamp"" AS ""Sorttime"" FROM ""Fileset"", ""FilesetEntry"", ""FileLookup"", ""BlocksetEntry"", ""Block"" WHERE ""FilesetEntry"".""FileID"" = ""FileLookup"".""ID"" AND ""FileLookup"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" "; var lastmodifiedMetadata = @" SELECT ""Block"".""VolumeID"" AS ""VolumeID"", ""Fileset"".""Timestamp"" AS ""Sorttime"" FROM ""Fileset"", ""FilesetEntry"", ""FileLookup"", ""BlocksetEntry"", ""Block"", ""Metadataset"" WHERE ""FilesetEntry"".""FileID"" = ""FileLookup"".""ID"" AND ""FileLookup"".""MetadataID"" = ""Metadataset"".""ID"" AND ""Metadataset"".""BlocksetID"" = ""BlocksetEntry"".""BlocksetID"" AND ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" "; var scantime = @$" SELECT ""VolumeID"" AS ""VolumeID"", MIN(""Sorttime"") AS ""Sorttime"" FROM ( {lastmodifiedFile} UNION {lastmodifiedMetadata} ) GROUP BY ""VolumeID"" "; var active = @$" SELECT ""A"".""ActiveSize"" AS ""ActiveSize"", 0 AS ""InactiveSize"", ""A"".""VolumeID"" AS ""VolumeID"", CASE WHEN ""B"".""Sorttime"" IS NULL THEN 0 ELSE ""B"".""Sorttime"" END AS ""Sorttime"" FROM ({usedBlocks}) ""A"" LEFT OUTER JOIN ({scantime}) ""B"" ON ""B"".""VolumeID"" = ""A"".""VolumeID"" "; var inactive = @" SELECT 0 AS ""ActiveSize"", SUM(""Size"") AS ""InactiveSize"", ""VolumeID"" AS ""VolumeID"", 0 AS ""SortScantime"" FROM ""DeletedBlock"" GROUP BY ""VolumeID"" "; var empty = @" SELECT 0 AS ""ActiveSize"", 0 AS ""InactiveSize"", ""Remotevolume"".""ID"" AS ""VolumeID"", 0 AS ""SortScantime"" FROM ""Remotevolume"" WHERE ""Remotevolume"".""Type"" = @Type AND ""Remotevolume"".""State"" IN (@AllowedStates) AND ""Remotevolume"".""ID"" NOT IN ( SELECT ""VolumeID"" FROM ""Block"" ) "; var combined = $"{active} UNION {inactive} UNION {empty}"; var collected = @$" SELECT ""VolumeID"" AS ""VolumeID"", SUM(""ActiveSize"") AS ""ActiveSize"", SUM(""InactiveSize"") AS ""InactiveSize"", MAX(""Sorttime"") AS ""Sorttime"" FROM ({combined}) GROUP BY ""VolumeID"" "; var createtable = $"{@$"CREATE {TEMPORARY} TABLE ""{tmptablename}"" AS "}{collected}"; await using var cmd = m_connection.CreateCommand(m_rtr); try { await cmd .SetCommandAndParameters(createtable) .SetParameterValue("@Type", RemoteVolumeType.Blocks.ToString()) .ExpandInClauseParameterMssqlite("@AllowedStates", [ RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString() ]) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); cmd.SetCommandAndParameters($@" SELECT ""A"".""Name"", ""B"".""ActiveSize"", ""B"".""InactiveSize"", ""A"".""Size"" FROM ""Remotevolume"" ""A"", ""{tmptablename}"" ""B"" WHERE ""A"".""ID"" = ""B"".""VolumeID"" ORDER BY ""B"".""Sorttime"" ASC "); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) yield return new VolumeUsage( rd.ConvertValueToString(0) ?? "", rd.ConvertValueToInt64(1, 0) + rd.ConvertValueToInt64(2, 0), rd.ConvertValueToInt64(2, 0), rd.ConvertValueToInt64(3, 0) ); } finally { try { await cmd .ExecuteNonQueryAsync($@"DROP TABLE IF EXISTS ""{tmptablename}"" ", token) .ConfigureAwait(false); } catch { } } } /// /// Generates a report on the volumes that can be reclaimed or compacted. /// public interface ICompactReport { /// /// Gets the volumes that can be deleted. /// IEnumerable DeleteableVolumes { get; } /// /// Gets the volumes that can be compacted. /// IEnumerable CompactableVolumes { get; } /// /// Gets the report on whether reclamation or compaction should be performed. /// bool ShouldReclaim { get; } /// /// Gets the report on whether compaction should be performed. /// bool ShouldCompact { get; } /// /// Emits the report data to the log. /// void ReportCompactData(); } /// /// A report on the volumes that can be reclaimed or compacted. /// private class CompactReport : ICompactReport { /// /// The report to emit to the log. /// private readonly IEnumerable m_report; /// /// The volumes that can be fully deleted. /// private readonly IEnumerable m_cleandelete; /// /// The volumes that have wasted space. /// private readonly IEnumerable m_wastevolumes; /// /// The volumes that are smaller than the specified size. /// private readonly IEnumerable m_smallvolumes; /// /// The count of volumes that can be fully deleted. /// private readonly long m_deletablevolumes; /// /// The total size of wasted space across all volumes. /// private readonly long m_wastedspace; /// /// The total size of small volumes. /// private readonly long m_smallspace; /// /// The total size of all data across all volumes. /// private readonly long m_fullsize; /// /// The count of small volumes. /// private readonly long m_smallvolumecount; /// /// The threshold for wasted space percentage to trigger compaction. /// private readonly long m_wastethreshold; /// /// The size of the volume to trigger compaction. /// private readonly long m_volsize; /// /// The maximum number of small files to trigger compaction. /// private readonly long m_maxsmallfilecount; /// /// Initializes a new instance of the class. /// /// The size of the volume to trigger compaction. /// The threshold for wasted space percentage to trigger compaction. /// The size of small files to trigger compaction. /// The maximum number of small files to trigger compaction. /// The report data containing volume usage information. public CompactReport(long volsize, long wastethreshold, long smallfilesize, long maxsmallfilecount, IEnumerable report) { m_report = report; m_cleandelete = (from n in m_report where n.DataSize <= n.WastedSize select n).ToArray(); m_wastevolumes = from n in m_report where ((((n.WastedSize / (float)n.DataSize) * 100) >= wastethreshold) || (((n.WastedSize / (float)volsize) * 100) >= wastethreshold)) && !m_cleandelete.Contains(n) select n; m_smallvolumes = from n in m_report where n.CompressedSize <= smallfilesize && !m_cleandelete.Contains(n) select n; m_wastethreshold = wastethreshold; m_volsize = volsize; m_maxsmallfilecount = maxsmallfilecount; m_deletablevolumes = m_cleandelete.Count(); m_fullsize = report.Select(x => x.DataSize).Sum(); m_wastedspace = m_wastevolumes.Select(x => x.WastedSize).Sum(); m_smallspace = m_smallvolumes.Select(x => x.CompressedSize).Sum(); m_smallvolumecount = m_smallvolumes.Count(); } /// /// Emits the report data to the log. /// public void ReportCompactData() { var wastepercentage = ((m_wastedspace / (float)m_fullsize) * 100); Logging.Log.WriteVerboseMessage(LOGTAG, "FullyDeletableCount", "Found {0} fully deletable volume(s)", m_deletablevolumes); Logging.Log.WriteVerboseMessage(LOGTAG, "SmallVolumeCount", "Found {0} small volumes(s) with a total size of {1}", m_smallvolumes.Count(), Library.Utility.Utility.FormatSizeString(m_smallspace)); Logging.Log.WriteVerboseMessage(LOGTAG, "WastedSpaceVolumes", "Found {0} volume(s) with a total of {1:F2}% wasted space ({2} of {3})", m_wastevolumes.Count(), wastepercentage, Library.Utility.Utility.FormatSizeString(m_wastedspace), Library.Utility.Utility.FormatSizeString(m_fullsize)); if (m_deletablevolumes > 0) Logging.Log.WriteInformationMessage(LOGTAG, "CompactReason", "Compacting because there are {0} fully deletable volume(s)", m_deletablevolumes); else if (wastepercentage >= m_wastethreshold && m_wastevolumes.Count() >= 2) Logging.Log.WriteInformationMessage(LOGTAG, "CompactReason", "Compacting because there is {0:F2}% wasted space and the limit is {1}%", wastepercentage, m_wastethreshold); else if (m_smallspace > m_volsize) Logging.Log.WriteInformationMessage(LOGTAG, "CompactReason", "Compacting because there are {0} in small volumes and the volume size is {1}", Library.Utility.Utility.FormatSizeString(m_smallspace), Library.Utility.Utility.FormatSizeString(m_volsize)); else if (m_smallvolumecount > m_maxsmallfilecount) Logging.Log.WriteInformationMessage(LOGTAG, "CompactReason", "Compacting because there are {0} small volumes and the maximum is {1}", m_smallvolumecount, m_maxsmallfilecount); else Logging.Log.WriteInformationMessage(LOGTAG, "CompactReason", "Compacting not required"); } /// /// Gets a value indicating whether reclamation should be performed. /// public bool ShouldReclaim { get { return m_deletablevolumes > 0; } } /// /// Gets a value indicating whether compaction should be performed. /// public bool ShouldCompact { get { return (((m_wastedspace / (float)m_fullsize) * 100) >= m_wastethreshold && m_wastevolumes.Count() >= 2) || m_smallspace > m_volsize || m_smallvolumecount > m_maxsmallfilecount; } } /// /// Gets the volumes that can be deleted. /// public IEnumerable DeleteableVolumes { get { return from n in m_cleandelete select n.Name; } } /// /// Gets the volumes that can be compacted. /// public IEnumerable CompactableVolumes { get { //The order matters, we compact old volumes together first, // as we anticipate old data will stay around, where never data // is more likely to be discarded again return m_wastevolumes.Union(m_smallvolumes).Select(x => x.Name).Distinct(); } } } /// /// Gets a compact report on the volumes that can be reclaimed or compacted. /// /// The size of the volume to trigger compaction. /// The threshold for wasted space percentage to trigger compaction. /// The size of small files to trigger compaction. /// The maximum number of small files to trigger compaction. /// A cancellation token to cancel the operation. /// A task that when awaited contains an instance of . public async Task GetCompactReport(long volsize, long wastethreshold, long smallfilesize, long maxsmallfilecount, CancellationToken token) { return new CompactReport( volsize, wastethreshold, smallfilesize, maxsmallfilecount, await GetWastedSpaceReport(token) .ToListAsync(cancellationToken: token) .ConfigureAwait(false) ); } /// /// Registers a block as duplicated to a new volume. /// This is used when a block is moved to a new volume, and we want to keep track of it. /// public interface IBlockQuery : IDisposable { /// /// Checks if a block is in use. If volumeId is not -1, check specific volume. /// /// The hash of the block. /// The size of the block. /// The volume ID to check, or -1 to check all volumes. /// A cancellation token to cancel the operation. /// A task that when awaited returns true if the block is in use, false otherwise. Task UseBlock(string hash, long size, long volumeId, CancellationToken token); } /// /// A helper class to query blocks in the database. /// private class BlockQuery : IBlockQuery { /// /// The database instance to use for the queries. /// private LocalDatabase m_db = null!; /// /// The command used to query blocks in the database. /// private SqliteCommand m_command = null!; [Obsolete("Calling this constructor will throw an exception. Use CreateAsync() instead.")] public BlockQuery(SqliteConnection connection, SqliteTransaction? transaction) { throw new NotImplementedException("Use CreateAsync() instead"); } /// /// Constructs a new instance of the class. /// It is private to prevent instantiation without CreateAsync. /// private BlockQuery() { } /// /// Creates a new instance of the class. /// /// The local database to use for the queries. /// A cancellation token to cancel the operation. /// A task that when awaited returns a new instance of . public static async Task CreateAsync(LocalDatabase db, CancellationToken token) { return new BlockQuery { m_db = db, m_command = await db.Connection.CreateCommandAsync(@" SELECT ""VolumeID"" FROM ""Block"" WHERE ""Hash"" = @Hash AND ""Size"" = @Size ", token) .ConfigureAwait(false) }; } /// public async Task UseBlock(string hash, long size, long volumeId, CancellationToken token) { var r = await m_command .SetTransaction(m_db.Transaction) .SetParameterValue("@Hash", hash) .SetParameterValue("@Size", size) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); if (r == -1) { return false; } else if (volumeId == -1) { return true; } else { // Check that the volume id matches return r == volumeId; } } public void Dispose() { if (m_command != null) try { m_command.Dispose(); } finally { m_command = null!; } } } /// /// Builds a lookup table to enable faster response to block queries. /// /// A cancellation token to cancel the operation. public async Task CreateBlockQueryHelper(CancellationToken token) { return await BlockQuery.CreateAsync(this, token).ConfigureAwait(false); } /// /// Registers a block as moved to a new volume. /// /// The hash of the block. /// The size of the block. /// The new volume ID. /// A cancellation token to cancel the operation. /// A task that when completed indicates the block has been registered. public async Task RegisterDuplicatedBlock(string hash, long size, long volumeID, CancellationToken token) { // Using INSERT OR IGNORE to avoid duplicate entries, result may be 1 or 0 await m_registerDuplicateBlockCommand .SetTransaction(m_rtr) .SetParameterValue("@VolumeId", volumeID) .SetParameterValue("@Hash", hash) .SetParameterValue("@Size", size) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); } /// /// After new volumes are uploaded, this method will update the blocks from the old volumes to point to the new volumes. /// /// The file to remove. /// The volume IDs that will be removed. /// A cancellation token to cancel the operation. /// A task that when completed indicates the operation has finished. public async Task PrepareForDelete(string filename, IEnumerable volumeIdsToBeRemoved, CancellationToken token) { var deletedVolume = await GetRemoteVolume(filename, token) .ConfigureAwait(false); if (deletedVolume.Type != RemoteVolumeType.Blocks) return; await using var cmd = m_connection.CreateCommand(m_rtr); var updatedBlocks = $"BlocksToUpdate-{Library.Utility.Utility.GetHexGuid()}"; var replacementBlocks = $"ReplacementBlocks-{Library.Utility.Utility.GetHexGuid()}"; try { await cmd.SetCommandAndParameters($@" CREATE {TEMPORARY} TABLE ""{updatedBlocks}"" AS SELECT ""ID"" FROM ""Block"" WHERE ""VolumeID"" = @VolumeId ") .SetParameterValue("@VolumeId", deletedVolume.ID) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); await using (var tempTable = await TemporaryDbValueList.CreateAsync(this, volumeIdsToBeRemoved, token).ConfigureAwait(false)) await ( await cmd.SetCommandAndParameters($@" CREATE {TEMPORARY} TABLE ""{replacementBlocks}"" AS SELECT ""BlockID"", MAX(""VolumeID"") AS ""VolumeID"" FROM ""DuplicateBlock"" WHERE ""VolumeID"" NOT IN (@VolumeIds) AND ""BlockID"" IN ( SELECT ""ID"" FROM ""{updatedBlocks}"" ) GROUP BY ""BlockID"" ") .ExpandInClauseParameterMssqliteAsync("@VolumeIds", tempTable, token) .ConfigureAwait(false) ) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); var targetCount = await cmd.ExecuteScalarInt64Async($@" SELECT COUNT(*) FROM ""{updatedBlocks}"" ", token) .ConfigureAwait(false); if (targetCount == 0) return; var replacementCount = await cmd.ExecuteScalarInt64Async($@" SELECT COUNT(*) FROM ""{replacementBlocks}"" ", token) .ConfigureAwait(false); var updateCount = await cmd.SetCommandAndParameters(@$" UPDATE ""Block"" SET ""VolumeID"" = ( SELECT ""VolumeID"" FROM ""{replacementBlocks}"" WHERE ""{replacementBlocks}"".""BlockID"" = ""Block"".""ID"" AND ""Block"".""VolumeID"" = @VolumeId ) WHERE ""Block"".""VolumeID"" = @VolumeId ") .SetParameterValue("@VolumeId", deletedVolume.ID) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); var deleteCount = await cmd.ExecuteNonQueryAsync(@$" DELETE FROM ""DuplicateBlock"" WHERE ( ""DuplicateBlock"".""BlockID"" || ':' || ""DuplicateBlock"".""VolumeID"" ) IN ( SELECT ""RB"".""BlockID"" || ':' || ""RB"".""VolumeID"" FROM ""{replacementBlocks}"" ""RB"" ) ", token) .ConfigureAwait(false); if (targetCount != updateCount || replacementCount != deleteCount || updateCount != deleteCount) { throw new Exception($"Unexpected number of rows updated. Expected {targetCount} but got updated {updateCount}, deleted {deleteCount}, and replaced {replacementCount}"); } // Remove knowledge of any old blocks await cmd.SetCommandAndParameters(@$" DELETE FROM ""DuplicateBlock"" WHERE ""VolumeID"" = @VolumeId ") .SetParameterValue("@VolumeId", deletedVolume.ID) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); } finally { try { await cmd.ExecuteNonQueryAsync($@"DROP TABLE IF EXISTS ""{updatedBlocks}"" ", token) .ConfigureAwait(false); } catch { } try { await cmd.ExecuteNonQueryAsync($@"DROP TABLE IF EXISTS ""{replacementBlocks}"" ", token) .ConfigureAwait(false); } catch { } } } /// /// Calculates the sequence in which files should be deleted based on their relations. /// /// Block volumes slated for deletion. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of that represents the order in which volumes should be deleted. public async IAsyncEnumerable ReOrderDeleteableVolumes(IEnumerable deleteableVolumes, [EnumeratorCancellation] CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); // Although the generated index volumes are always in pairs, // this code handles many-to-many relations between // index files and block volumes, should this be added later var lookupBlock = new Dictionary>(); var lookupIndexfiles = new Dictionary>(); cmd.SetCommandAndParameters(@" SELECT ""C"".""Name"", ""B"".""Name"", ""B"".""Hash"", ""B"".""Size"" FROM ""IndexBlockLink"" A, ""RemoteVolume"" B, ""RemoteVolume"" C WHERE ""A"".""IndexVolumeID"" = ""B"".""ID"" AND ""A"".""BlockVolumeID"" = ""C"".""ID"" AND ""B"".""Hash"" IS NOT NULL AND ""B"".""Size"" IS NOT NULL "); await using (var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false)) while (await rd.ReadAsync(token).ConfigureAwait(false)) { var name = rd.ConvertValueToString(0) ?? ""; if (!lookupBlock.TryGetValue(name, out var indexfileList)) { indexfileList = new List(); lookupBlock.Add(name, indexfileList); } var v = new RemoteVolume( rd.ConvertValueToString(1), rd.ConvertValueToString(2), rd.ConvertValueToInt64(3) ); indexfileList.Add(v); if (!lookupIndexfiles.TryGetValue(v.Name, out var blockList)) { blockList = new List(); lookupIndexfiles.Add(v.Name, blockList); } blockList.Add(name); } foreach (var r in deleteableVolumes.Distinct()) { // Return the input yield return r; if (lookupBlock.TryGetValue(r.Name, out var indexfileList)) foreach (var sh in indexfileList) { if (lookupIndexfiles.TryGetValue(sh.Name, out var backref)) { //If this is the last reference, // remove the index file as well if (backref.Remove(r.Name) && backref.Count == 0) yield return sh; } } } } } }