// 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.Utility; using Microsoft.Data.Sqlite; namespace Duplicati.Library.Main.Database { /// /// A local backup database that stores blocks, files, and metadata for backup operations. /// This database is used to track the state of backups and to allow for efficient retrieval of blocks and files. /// It supports operations such as finding blocks by hash and size, inserting new blocks, files, and blocksets, /// and managing metadata datasets. /// internal class LocalBackupDatabase : LocalDatabase { /// /// The tag used for logging. /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The command used to find a block by its hash and size. /// private SqliteCommand m_findblockCommand = null!; /// /// The command used to find a blockset by its full hash and length. /// private SqliteCommand m_findblocksetCommand = null!; /// /// The command used to find a metadataset by its block hash and size. /// private SqliteCommand m_findfilesetCommand = null!; /// /// The command used to find a metadataset by its block hash and size. /// private SqliteCommand m_findmetadatasetCommand = null!; /// /// The command used to insert a block into the database. /// private SqliteCommand m_insertblockCommand = null!; /// /// The command used to insert a file into the database. /// private SqliteCommand m_insertfileCommand = null!; /// /// The command used to insert a blockset into the database. /// private SqliteCommand m_insertblocksetCommand = null!; /// /// The command used to insert a blockset entry into the database. /// private SqliteCommand m_insertblocksetentryCommand = null!; /// /// The command used to insert a blocklist hash into the database. /// private SqliteCommand m_insertblocklistHashesCommand = null!; /// /// The command used to insert a metadataset into the database. /// private SqliteCommand m_insertmetadatasetCommand = null!; /// /// The command used to find a file in the database. /// private SqliteCommand m_findfileCommand = null!; /// /// The command used to select the last modified time of a file. /// private SqliteCommand m_selectfilelastmodifiedCommand = null!; /// /// The command used to select the last modified time and size of a file. /// private SqliteCommand m_selectfilelastmodifiedWithSizeCommand = null!; /// /// The command used to select the hash and size of a file's metadata. /// private SqliteCommand m_selectfileHashCommand = null!; /// /// The command used to insert a file operation into the database. /// private SqliteCommand m_insertfileOperationCommand = null!; /// /// The command used to select the metadata hash and size of a file. /// private SqliteCommand m_selectfilemetadatahashandsizeCommand = null!; /// /// The command used to find the first fileset with a block in a blockset. /// private SqliteCommand m_getfirstfilesetwithblockinblockset = null!; /// /// HashSet of blocklist hashes to track whether a blocklist hash has been seen before. /// private HashSet m_blocklistHashes = []; /// /// The temporary table with deleted blocks that can be re-used; null if not table is used /// private string? m_tempDeletedBlockTable; /// /// The in-mmeory lookup for deleted blocks; null if in-memory lookup is not used /// private Dictionary>? m_deletedBlockLookup; /// /// The command used to move deleted blocks to the main block table; null if not used /// private SqliteCommand? m_moveblockfromdeletedCommand; /// /// The command used to find blocks in the deleted blocks table; null if not used /// private SqliteCommand? m_findindeletedCommand; /// /// The ID of the fileset currently being processed. /// private long m_filesetId; /// /// Indicates whether the database should log queries for profiling purposes. /// private bool m_logQueries; /// /// Initializes a new instance of the class. /// This constructor is private to enforce the use of the static and asynchronous CreateAsync methods for instantiation. /// /// The path to the database file. /// The Duplicati options used by the current operation. /// An optional existing instance of to reuse. Used when derived classes need to call the base constructor. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(string path, Options options, CancellationToken token, LocalBackupDatabase? dbnew = null) { dbnew ??= new LocalBackupDatabase(); dbnew = (LocalBackupDatabase) await CreateLocalDatabaseAsync(path, "Backup", false, dbnew, token) .ConfigureAwait(false); dbnew = await CreateAsync(dbnew, options, null, token).ConfigureAwait(false); dbnew.ShouldCloseConnection = true; return dbnew; } /// /// Creates a new instance of using an existing parent database. /// This method is used to create a new backup database based on an existing local database. /// /// The parent local database from which to create the new backup database. /// The Duplicati options used by the current operation. /// An optional existing instance of to reuse. Used when derived classes need to call the base constructor. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(LocalDatabase dbparent, Options options, LocalBackupDatabase? dbnew, CancellationToken token) { dbnew ??= new LocalBackupDatabase(); dbnew = (LocalBackupDatabase)await CreateLocalDatabaseAsync(dbparent, dbnew, token) .ConfigureAwait(false); dbnew.m_logQueries = options.ProfileAllDatabaseQueries; dbnew.m_findblockCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""ID"" FROM ""Block"" WHERE ""Hash"" = @Hash AND ""Size"" = @Size ", token) .ConfigureAwait(false); dbnew.m_findblocksetCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""ID"" FROM ""Blockset"" WHERE ""Fullhash"" = @Fullhash AND ""Length"" = @Length ", token) .ConfigureAwait(false); dbnew.m_findmetadatasetCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""A"".""ID"" FROM ""Metadataset"" ""A"", ""BlocksetEntry"" ""B"", ""Block"" ""C"" WHERE ""A"".""BlocksetID"" = ""B"".""BlocksetID"" AND ""B"".""BlockID"" = ""C"".""ID"" AND ""C"".""Hash"" = @Hash AND ""C"".""Size"" = @Size ", token) .ConfigureAwait(false); dbnew.m_findfilesetCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""ID"" FROM ""FileLookup"" WHERE ""BlocksetID"" = @BlocksetId AND ""MetadataID"" = @MetadataId AND ""Path"" = @Path AND ""PrefixID"" = @PrefixId ", token) .ConfigureAwait(false); dbnew.m_insertblockCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""Block"" ( ""Hash"", ""VolumeID"", ""Size"" ) VALUES ( @Hash, @VolumeId, @Size ); SELECT last_insert_rowid(); ", token) .ConfigureAwait(false); dbnew.m_insertfileOperationCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""FilesetEntry"" ( ""FilesetID"", ""FileID"", ""Lastmodified"" ) VALUES ( @FilesetId, @FileId, @LastModified ) ", token) .ConfigureAwait(false); dbnew.m_insertfileCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""FileLookup"" ( ""PrefixID"", ""Path"", ""BlocksetID"", ""MetadataID"" ) VALUES ( @PrefixId, @Path, @BlocksetId, @MetadataId ); SELECT last_insert_rowid(); ", token) .ConfigureAwait(false); dbnew.m_insertblocksetCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""Blockset"" ( ""Length"", ""FullHash"" ) VALUES ( @Length, @Fullhash ); SELECT last_insert_rowid(); ", token) .ConfigureAwait(false); dbnew.m_insertblocksetentryCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""BlocksetEntry"" ( ""BlocksetID"", ""Index"", ""BlockID"" ) SELECT @BlocksetId AS ""A"", @Index AS ""B"", ""ID"" FROM ""Block"" WHERE ""Hash"" = @Hash AND ""Size"" = @Size ", token) .ConfigureAwait(false); dbnew.m_insertblocklistHashesCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""BlocklistHash"" ( ""BlocksetID"", ""Index"", ""Hash"" ) VALUES ( @BlocksetId, @Index, @Hash ) ", token) .ConfigureAwait(false); dbnew.m_insertmetadatasetCommand = await dbnew.Connection.CreateCommandAsync(@" INSERT INTO ""Metadataset"" (""BlocksetID"") VALUES (@BlocksetId); SELECT last_insert_rowid(); ", token) .ConfigureAwait(false); dbnew.m_selectfilelastmodifiedCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""A"".""ID"", ""B"".""LastModified"" FROM ( SELECT ""ID"" FROM ""FileLookup"" WHERE ""PrefixID"" = @PrefixId AND ""Path"" = @Path ) ""A"" CROSS JOIN ""FilesetEntry"" ""B"" WHERE ""A"".""ID"" = ""B"".""FileID"" AND ""B"".""FilesetID"" = @FilesetId ", token) .ConfigureAwait(false); dbnew.m_selectfilelastmodifiedWithSizeCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""C"".""ID"", ""C"".""LastModified"", ""D"".""Length"" FROM ( SELECT ""A"".""ID"", ""B"".""LastModified"", ""A"".""BlocksetID"" FROM ( SELECT ""ID"", ""BlocksetID"" FROM ""FileLookup"" WHERE ""PrefixID"" = @PrefixId AND ""Path"" = @Path ) ""A"" CROSS JOIN ""FilesetEntry"" ""B"" WHERE ""A"".""ID"" = ""B"".""FileID"" AND ""B"".""FilesetID"" = @FilesetId ) AS ""C"", ""Blockset"" AS ""D"" WHERE ""C"".""BlocksetID"" == ""D"".""ID"" ", token) .ConfigureAwait(false); dbnew.m_selectfilemetadatahashandsizeCommand = await dbnew.Connection.CreateCommandAsync(@" SELECT ""Blockset"".""Length"", ""Blockset"".""FullHash"" FROM ""Blockset"", ""Metadataset"", ""File"" WHERE ""File"".""ID"" = @FileId AND ""Blockset"".""ID"" = ""Metadataset"".""BlocksetID"" AND ""Metadataset"".""ID"" = ""File"".""MetadataID"" ", token) .ConfigureAwait(false); // Experimental toggling of the deleted block cache // If the value is less than zero, the lookup is disabled // meaning that deleted blocks are never reused (same as 2.1.0.5 and earlier) // A value of zero disables the in-memory cache, always using a temporary table // Any other value is the size of the in-memory cache // If the number of deleted blocks exceed the cache size, a temporary table is used var deletedBlockCacheSize = Environment.GetEnvironmentVariable("DUPLICATI_DELETEDBLOCKCACHESIZE"); if (!long.TryParse(deletedBlockCacheSize, out var deletedBlockCacheSizeLong)) deletedBlockCacheSizeLong = 10000; if (deletedBlockCacheSizeLong >= 0) { await using var cmd = dbnew.Connection.CreateCommand(); dbnew.m_tempDeletedBlockTable = $"DeletedBlock-{Library.Utility.Utility.GetHexGuid()}"; await cmd.SetCommandAndParameters($@" CREATE TEMPORARY TABLE ""{dbnew.m_tempDeletedBlockTable}"" AS SELECT MAX(""ID"") AS ""ID"", ""Hash"", ""Size"" FROM ""DeletedBlock"" WHERE ""VolumeID"" IN ( SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""State"" NOT IN (@States) ) GROUP BY ""Hash"", ""Size"" ") .ExpandInClauseParameterMssqlite("@States", [ RemoteVolumeState.Deleted, RemoteVolumeState.Deleting ]) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); var deletedBlocks = await cmd.ExecuteScalarInt64Async(@$" SELECT COUNT(*) FROM ""{dbnew.m_tempDeletedBlockTable}"" ", 0, token) .ConfigureAwait(false); // There are no deleted blocks, so we can drop the table if (deletedBlocks == 0) { await cmd.ExecuteNonQueryAsync($@"DROP TABLE ""{dbnew.m_tempDeletedBlockTable}""", token) .ConfigureAwait(false); dbnew.m_tempDeletedBlockTable = null; } // The deleted blocks are small enough to fit in memory else if (deletedBlocks <= deletedBlockCacheSizeLong) { dbnew.m_deletedBlockLookup = new Dictionary>(); cmd.SetCommandAndParameters(@$" SELECT ""ID"", ""Hash"", ""Size"" FROM ""{dbnew.m_tempDeletedBlockTable}"" ") .ConfigureAwait(false); await using (var reader = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false)) while (await reader.ReadAsync(token).ConfigureAwait(false)) { var id = reader.ConvertValueToInt64(0); var hash = reader.ConvertValueToString(1) ?? throw new Exception("Hash is null"); var size = reader.ConvertValueToInt64(2); if (!dbnew.m_deletedBlockLookup.TryGetValue(hash, out var sizes)) dbnew.m_deletedBlockLookup[hash] = sizes = new Dictionary(); sizes[size] = id; } await cmd.ExecuteNonQueryAsync($@"DROP TABLE ""{dbnew.m_tempDeletedBlockTable}""", token) .ConfigureAwait(false); dbnew.m_tempDeletedBlockTable = null; } // The deleted blocks are too large to fit in memory, so we use a temporary table else { await cmd.ExecuteNonQueryAsync($@" CREATE UNIQUE INDEX ""unique_{dbnew.m_tempDeletedBlockTable}"" ON ""{dbnew.m_tempDeletedBlockTable}"" ( ""Hash"", ""Size"" ) ", token) .ConfigureAwait(false); dbnew.m_findindeletedCommand = await dbnew.Connection.CreateCommandAsync($@" SELECT ""ID"" FROM ""{dbnew.m_tempDeletedBlockTable}"" WHERE ""Hash"" = @Hash AND ""Size"" = @Size ", token) .ConfigureAwait(false); dbnew.m_moveblockfromdeletedCommand = await dbnew.Connection.CreateCommandAsync(@$" INSERT INTO ""Block"" ( ""Hash"", ""Size"", ""VolumeID"" ) SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""DeletedBlock"" WHERE ""ID"" = @DeletedBlockId LIMIT 1; DELETE FROM ""DeletedBlock"" WHERE ""ID"" = @DeletedBlockId; DELETE FROM ""{dbnew.m_tempDeletedBlockTable}"" WHERE ""ID"" = @DeletedBlockId; SELECT last_insert_rowid() ", token) .ConfigureAwait(false); } if (deletedBlocks > 0) { dbnew.m_moveblockfromdeletedCommand = await dbnew.m_connection.CreateCommandAsync(@$" INSERT INTO ""Block"" ( ""Hash"", ""Size"", ""VolumeID"" ) SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""DeletedBlock"" WHERE ""ID"" = @DeletedBlockId LIMIT 1; DELETE FROM ""DeletedBlock"" WHERE ""ID"" = @DeletedBlockId; SELECT last_insert_rowid() ", token) .ConfigureAwait(false); } } // Allow users to test on real-world data // to get feedback on potential performance int.TryParse(Environment.GetEnvironmentVariable("TEST_QUERY_VERSION"), out var testqueryversion); if (testqueryversion != 0) Logging.Log.WriteWarningMessage(LOGTAG, "TestFileQuery", null, "Using performance test query version {0} as the TEST_QUERY_VERSION environment variable is set", testqueryversion); // The original query (v==1) finds the most recent entry of the file in question, // but it requires some large joins to extract the required information. // To speed it up, we use a slightly simpler approach that only looks at the // previous fileset, and uses information here. // If there is a case where a file is sometimes there and sometimes not // (i.e. filter file, remove filter) we will not find the file. // We currently use this faster version, // but allow users to switch back via an environment variable // such that we can get performance feedback string findQuery; switch (testqueryversion) { // The query used in Duplicati until 2.0.3.9 case 1: findQuery = @" SELECT ""FileLookup"".""ID"" AS ""FileID"", ""FilesetEntry"".""Lastmodified"", ""FileBlockset"".""Length"", ""MetaBlockset"".""Fullhash"" AS ""Metahash"", ""MetaBlockset"".""Length"" AS ""Metasize"" FROM ""FileLookup"", ""FilesetEntry"", ""Fileset"", ""Blockset"" ""FileBlockset"", ""Metadataset"", ""Blockset"" ""MetaBlockset"" WHERE ""FileLookup"".""PrefixID"" = @PrefixId AND ""FileLookup"".""Path"" = @Path AND ""FilesetEntry"".""FileID"" = ""FileLookup"".""ID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" AND ""FileBlockset"".""ID"" = ""FileLookup"".""BlocksetID"" AND ""Metadataset"".""ID"" = ""FileLookup"".""MetadataID"" AND ""MetaBlockset"".""ID"" = ""Metadataset"".""BlocksetID"" AND @FilesetId IS NOT NULL ORDER BY ""Fileset"".""Timestamp"" DESC LIMIT 1 "; break; // The fastest reported query in Duplicati 2.0.3.10, but with "LIMIT 1" added default: case 2: var getLastFileEntryForPath = @" SELECT ""A"".""ID"", ""B"".""LastModified"", ""A"".""BlocksetID"", ""A"".""MetadataID"" FROM ( SELECT ""ID"", ""BlocksetID"", ""MetadataID"" FROM ""FileLookup"" WHERE ""PrefixID"" = @PrefixId AND ""Path"" = @Path ) ""A"" CROSS JOIN ""FilesetEntry"" ""B"" WHERE ""A"".""ID"" = ""B"".""FileID"" AND ""B"".""FilesetID"" = @FilesetId "; findQuery = $@" SELECT ""C"".""ID"" AS ""FileID"", ""C"".""LastModified"", ""D"".""Length"", ""E"".""FullHash"" as ""Metahash"", ""E"".""Length"" AS ""Metasize"" FROM ({getLastFileEntryForPath}) AS ""C"", ""Blockset"" AS ""D"", ""Blockset"" AS ""E"", ""Metadataset"" ""F"" WHERE ""C"".""BlocksetID"" == ""D"".""ID"" AND ""C"".""MetadataID"" == ""F"".""ID"" AND ""F"".""BlocksetID"" = ""E"".""ID"" LIMIT 1 "; break; // Potentially faster query: https://forum.duplicati.com/t/release-2-0-3-10-canary-2018-08-30/4497/25 case 3: findQuery = @" SELECT ""FileLookup"".""ID"" as ""FileID"", ""FilesetEntry"".""Lastmodified"", ""FileBlockset"".""Length"", ""MetaBlockset"".""FullHash"" AS ""Metahash"", ""MetaBlockset"".""Length"" as ""Metasize"" FROM ""FilesetEntry"" INNER JOIN ""Fileset"" ON (""FileSet"".""ID"" = ""FilesetEntry"".FilesetID) INNER JOIN ""FileLookup"" ON (""FileLookup"".""ID"" = ""FilesetEntry"".""FileID"") INNER JOIN ""Metadataset"" ON (""Metadataset"".""ID"" = ""FileLookup"".""MetadataID"") INNER JOIN Blockset AS ""MetaBlockset"" ON (""MetaBlockset"".""ID"" = ""Metadataset"".""BlocksetID"") LEFT JOIN Blockset AS ""FileBlockset"" ON (""FileBlockset"".""ID"" = ""FileLookup"".""BlocksetID"") WHERE ""FileLookup"".""PrefixID"" = @PrefixId AND ""FileLookup"".""Path"" = @Path AND FilesetID = @FilesetId LIMIT 1 "; break; // The slow query used in Duplicati 2.0.3.10, but with "LIMIT 1" added case 4: findQuery = @" SELECT ""FileLookup"".""ID"" AS ""FileID"", ""FilesetEntry"".""Lastmodified"", ""FileBlockset"".""Length"", ""MetaBlockset"".""Fullhash"" AS ""Metahash"", ""MetaBlockset"".""Length"" AS ""Metasize"" FROM ""FileLookup"", ""FilesetEntry"", ""Fileset"", ""Blockset"" ""FileBlockset"", ""Metadataset"", ""Blockset"" ""MetaBlockset"" WHERE ""FileLookup"".""PrefixID"" = @PrefixId AND ""FileLookup"".""Path"" = @Path AND ""Fileset"".""ID"" = @FilesetId AND ""FilesetEntry"".""FileID"" = ""FileLookup"".""ID"" AND ""Fileset"".""ID"" = ""FilesetEntry"".""FilesetID"" AND ""FileBlockset"".""ID"" = ""FileLookup"".""BlocksetID"" AND ""Metadataset"".""ID"" = ""FileLookup"".""MetadataID"" AND ""MetaBlockset"".""ID"" = ""Metadataset"".""BlocksetID"" LIMIT 1 "; break; } dbnew.m_findfileCommand = dbnew.m_connection.CreateCommand(findQuery); dbnew.m_selectfileHashCommand = dbnew.m_connection.CreateCommand(@" SELECT ""Blockset"".""Fullhash"" FROM ""Blockset"", ""FileLookup"" WHERE ""Blockset"".""ID"" = ""FileLookup"".""BlocksetID"" AND ""FileLookup"".""ID"" = @FileId "); dbnew.m_getfirstfilesetwithblockinblockset = dbnew.m_connection.CreateCommand(@" SELECT MIN(""FilesetEntry"".""FilesetID"") FROM ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" IN ( SELECT ""File"".""ID"" FROM ""File"" WHERE ""File"".""BlocksetID"" IN( SELECT ""BlocklistHash"".""BlocksetID"" FROM ""BlocklistHash"" WHERE ""BlocklistHash"".""Hash"" = @Hash ) ) "); dbnew.m_blocklistHashes = new HashSet(); return dbnew; } /// /// Probes to see if a block already exists. /// /// The block key. /// The size of the block. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains true if the block should be added to the current output. public async Task FindBlockID(string key, long size, CancellationToken token) { return await m_findblockCommand .SetTransaction(m_rtr) .SetParameterValue("@Hash", key) .SetParameterValue("@Size", size) .ExecuteScalarInt64Async(m_logQueries, -1, token) .ConfigureAwait(false); } /// /// Adds a block to the local database, returning a value indicating if the value presents a new block. /// /// The block key. /// The size of the block. /// The ID of the volume to which the block belongs. /// The cancellation token to monitor for cancellation requests. /// A taskt that when awaited contains true if the block should be added to the current output. public async Task AddBlock(string key, long size, long volumeid, CancellationToken token) { var r = await FindBlockID(key, size, token).ConfigureAwait(false); if (r == -1L) { if (m_moveblockfromdeletedCommand != null) { if (m_deletedBlockLookup != null) { if (m_deletedBlockLookup.TryGetValue(key, out var sizes)) if (sizes.TryGetValue(size, out var id)) { await m_moveblockfromdeletedCommand .SetTransaction(m_rtr) .SetParameterValue("@DeletedBlockId", id) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); sizes.Remove(size); if (sizes.Count == 0) m_deletedBlockLookup.Remove(key); return false; } } else if (m_findindeletedCommand != null) { // No transaction on the temporary table var id = await m_findindeletedCommand .SetTransaction(m_rtr) .SetParameterValue("@Hash", key) .SetParameterValue("@Size", size) .ExecuteScalarInt64Async(m_logQueries, -1, token) .ConfigureAwait(false); if (id != -1) { var c = await m_moveblockfromdeletedCommand .SetTransaction(m_rtr) .SetParameterValue("@DeletedBlockId", id) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); if (c != 2) throw new Exception($"Failed to move block {key} with size {size}, result count: {c}"); // We do not clean up the temporary table, as the regular block lookup should now find it return false; } } } var ins = await m_insertblockCommand .SetTransaction(m_rtr) .SetParameterValue("@Hash", key) .SetParameterValue("@VolumeId", volumeid) .SetParameterValue("@Size", size) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); if (ins != 1) throw new Exception($"Failed to insert block {key} with size {size}, result count: {ins}"); return true; } else { //Update lookup cache if required return false; } } /// /// Adds a blockset to the database, returns a value indicating if the blockset is new. /// /// The hash of the blockset. /// The size of the blockset. /// The size of the blocks in the blockset. /// The list of hashes. /// The list of hashes for the blocklist, or null if no blocklist is used. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains a tuple with the first value indicating whether the blockset was created, and the second value being the blockset ID. public async Task<(bool, long)> AddBlockset(string filehash, long size, int blocksize, IEnumerable hashes, IEnumerable blocklistHashes, CancellationToken token) { long blocksetid = await m_findblocksetCommand .SetTransaction(m_rtr) .SetParameterValue("@Fullhash", filehash) .SetParameterValue("@Length", size) .ExecuteScalarInt64Async(m_logQueries, -1, token) .ConfigureAwait(false); if (blocksetid != -1) return (false, blocksetid); //Found it blocksetid = await m_insertblocksetCommand .SetTransaction(m_rtr) .SetParameterValue("@Length", size) .SetParameterValue("@Fullhash", filehash) .ExecuteScalarInt64Async(m_logQueries, token) .ConfigureAwait(false); long ix = 0; if (blocklistHashes != null) { m_insertblocklistHashesCommand .SetTransaction(m_rtr) .SetParameterValue("@BlocksetId", blocksetid); foreach (var bh in blocklistHashes) { var c = await m_insertblocklistHashesCommand .SetParameterValue("@Index", ix) .SetParameterValue("@Hash", bh) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); if (c != 1) throw new Exception($"Failed to insert blocklist hash {bh} for blockset {blocksetid}, result count: {c}"); ix++; } } m_insertblocksetentryCommand .SetTransaction(m_rtr) .SetParameterValue("@BlocksetId", blocksetid); ix = 0; long remainsize = size; foreach (var h in hashes) { var exsize = remainsize < blocksize ? remainsize : blocksize; var c = await m_insertblocksetentryCommand .SetParameterValue("@Index", ix) .SetParameterValue("@Hash", h) .SetParameterValue("@Size", exsize) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); if (c != 1) { Logging.Log.WriteErrorMessage(LOGTAG, "CheckingErrorsForIssue1400", null, "Checking errors, related to #1400. Unexpected result count: {0}, expected {1}, hash: {2}, size: {3}, blocksetid: {4}, ix: {5}, fullhash: {6}, fullsize: {7}", c, 1, h, exsize, blocksetid, ix, filehash, size); await using (var cmd = m_connection.CreateCommand(m_rtr)) { var bid = await cmd.SetCommandAndParameters(@" SELECT ""ID"" FROM ""Block"" WHERE ""Hash"" = @Hash ") .SetParameterValue("@Hash", h) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); if (bid == -1) throw new Exception($"Could not find any blocks with the given hash: {h}"); cmd.SetCommandAndParameters(@" SELECT ""Size"" FROM ""Block"" WHERE ""Hash"" = @Hash ") .SetParameterValue("@Hash", h); await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) Logging.Log.WriteErrorMessage(LOGTAG, "FoundIssue1400Error", null, "Found block with ID {0} and hash {1} and size {2}", bid, h, rd.ConvertValueToInt64(0, -1)); } throw new Exception($"Unexpected result count: {c}, expected {1}, check log for more messages"); } ix++; remainsize -= blocksize; } await m_rtr.CommitAsync(token: token).ConfigureAwait(false); return (true, blocksetid); } /// /// Gets the metadataset ID from the filehash. /// /// The metadata hash. /// The size of the metadata. /// The cancellation token to cancel the operation. /// A task that when awaited contains a tuple with the first value indicating if the metadataset was found, and the second value being the metadataset ID. public async Task<(bool, long)> GetMetadatasetID(string filehash, long size, CancellationToken token) { long metadataid; if (size > 0) { metadataid = await m_findmetadatasetCommand .SetTransaction(m_rtr) .SetParameterValue("@Hash", filehash) .SetParameterValue("@Size", size) .ExecuteScalarInt64Async(m_logQueries, -1, token) .ConfigureAwait(false); return (metadataid != -1, metadataid); } metadataid = -2; return (false, metadataid); } /// /// Adds a metadata set to the database, and returns a tuple indicating if the record was new and the ID of the metadata set. /// /// The metadata hash. /// The size of the metadata. /// The id of the blockset to add. /// The cancellation token to cancel the operation. /// A task that when awaited contains a tuple with the first value indicating if the metadata set was added, and the second value being the metadata ID. public async Task<(bool, long)> AddMetadataset(string filehash, long size, long blocksetid, CancellationToken token) { var (metadatafound, metadataid) = await GetMetadatasetID(filehash, size, token) .ConfigureAwait(false); if (metadatafound) return (false, metadataid); metadataid = await m_insertmetadatasetCommand .SetTransaction(m_rtr) .SetParameterValue("@BlocksetId", blocksetid) .ExecuteScalarInt64Async(m_logQueries, token) .ConfigureAwait(false); await m_rtr.CommitAsync(token: token).ConfigureAwait(false); return (true, metadataid); } /// /// Adds a file record to the database. /// /// The path prefix ID. /// The path to the file. /// The time the file was modified. /// The ID of the hashkey for the file. /// The ID for the metadata. /// The cancellation token to cancel the operation. /// A task that completes when the file is added. public async Task AddFile(long pathprefixid, string filename, DateTime lastmodified, long blocksetID, long metadataID, CancellationToken token) { var fileidobj = await m_findfilesetCommand .SetTransaction(m_rtr) .SetParameterValue("@BlocksetId", blocksetID) .SetParameterValue("@MetadataId", metadataID) .SetParameterValue("@Path", filename) .SetParameterValue("@PrefixId", pathprefixid) .ExecuteScalarInt64Async(m_logQueries, token) .ConfigureAwait(false); if (fileidobj == -1) { fileidobj = await m_insertfileCommand.SetTransaction(m_rtr) .SetParameterValue("@PrefixId", pathprefixid) .SetParameterValue("@Path", filename) .SetParameterValue("@BlocksetId", blocksetID) .SetParameterValue("@MetadataId", metadataID) .ExecuteScalarInt64Async(m_logQueries, token) .ConfigureAwait(false); await m_rtr.CommitAsync(token).ConfigureAwait(false); } await AddKnownFile(fileidobj, lastmodified, token).ConfigureAwait(false); } /// /// Adds a file record to the database. /// /// The path to the file. /// The time the file was modified. /// The ID of the hashkey for the file. /// The ID for the metadata. /// The cancellation token to cancel the operation. /// A task that completes when the file is added. public async Task AddFile(string filename, DateTime lastmodified, long blocksetID, long metadataID, CancellationToken token) { var split = SplitIntoPrefixAndName(filename); await AddFile( await GetOrCreatePathPrefix(split.Key, token).ConfigureAwait(false), split.Value, lastmodified, blocksetID, metadataID, token ) .ConfigureAwait(false); } /// /// Adds a known file to the fileset. /// /// Id of the file. /// The time the file was modified. /// The cancellation token to cancel the operation. /// A task that completes when the file is added. public async Task AddKnownFile(long fileid, DateTime lastmodified, CancellationToken token) { await m_insertfileOperationCommand .SetTransaction(m_rtr) .SetParameterValue("@FilesetId", m_filesetId) .SetParameterValue("@FileId", fileid) .SetParameterValue("@LastModified", lastmodified.ToUniversalTime().Ticks) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); } /// /// Adds a directory entry to the fileset. /// /// The path to the directory. /// The ID for the metadata. /// The time the directory was modified. /// The cancellation token to cancel the operation. /// A task that completes when the directory entry is added. public async Task AddDirectoryEntry(string path, long metadataID, DateTime lastmodified, CancellationToken token) { await AddFile(path, lastmodified, FOLDER_BLOCKSET_ID, metadataID, token) .ConfigureAwait(false); } /// /// Adds a symlink entry to the fileset. /// /// The path to the symlink. /// The ID for the metadata. /// The time the symlink was modified. /// The cancellation token to cancel the operation. /// A task that completes when the symlink entry is added. public async Task AddSymlinkEntry(string path, long metadataID, DateTime lastmodified, CancellationToken token) { await AddFile(path, lastmodified, SYMLINK_BLOCKSET_ID, metadataID, token) .ConfigureAwait(false); } /// /// Gets the ID, last modified time and size of a file in the fileset. /// /// The ID of the path prefix. /// The path to the file. /// The ID of the fileset. /// Whether to include the file length in the result. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains a tuple with the file ID, last modified time, and file length. public async Task<(long, DateTime, long)> GetFileLastModified(long prefixid, string path, long filesetid, bool includeLength, CancellationToken token) { DateTime oldModified; long length; if (includeLength) { m_selectfilelastmodifiedWithSizeCommand .SetTransaction(m_rtr) .SetParameterValue("@PrefixId", prefixid) .SetParameterValue("@Path", path) .SetParameterValue("@FilesetId", filesetid); await using var rd = await m_selectfilelastmodifiedWithSizeCommand.ExecuteReaderAsync(m_logQueries, token).ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) { oldModified = new DateTime(rd.ConvertValueToInt64(1), DateTimeKind.Utc); length = rd.ConvertValueToInt64(2); return (rd.ConvertValueToInt64(0), oldModified, length); } } else { m_selectfilelastmodifiedCommand.SetTransaction(m_rtr) .SetParameterValue("@PrefixId", prefixid) .SetParameterValue("@Path", path) .SetParameterValue("@FilesetId", filesetid); await using var rd = await m_selectfilelastmodifiedCommand.ExecuteReaderAsync(m_logQueries, token).ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) { length = -1; oldModified = new DateTime(rd.ConvertValueToInt64(1), DateTimeKind.Utc); return (rd.ConvertValueToInt64(0), oldModified, length); } } oldModified = new DateTime(0, DateTimeKind.Utc); length = -1; return (-1, oldModified, length); } /// /// Gets the file entry for a given path in the fileset. /// /// The ID of the path prefix. /// The path to the file. /// The ID of the fileset. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains a tuple with the file ID, last modified time, file size, metadata hash, and metadata size. /// public async Task<(long, DateTime, long, string?, long)> GetFileEntry(long prefixid, string path, long filesetid, CancellationToken token) { DateTime oldModified; long lastFileSize; string? oldMetahash; long oldMetasize; m_findfileCommand .SetTransaction(m_rtr) .SetParameterValue("@PrefixId", prefixid) .SetParameterValue("@Path", path) .SetParameterValue("@FilesetId", filesetid); await using var rd = await m_findfileCommand.ExecuteReaderAsync(token).ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) { oldModified = new DateTime(rd.ConvertValueToInt64(1), DateTimeKind.Utc); lastFileSize = rd.ConvertValueToInt64(2); oldMetahash = rd.ConvertValueToString(3); oldMetasize = rd.ConvertValueToInt64(4); return ( rd.ConvertValueToInt64(0), oldModified, lastFileSize, oldMetahash, oldMetasize ); } else { oldModified = new DateTime(0, DateTimeKind.Utc); lastFileSize = -1; oldMetahash = null; oldMetasize = -1; return ( -1, oldModified, lastFileSize, oldMetahash, oldMetasize ); } } /// /// Gets the metadata hash and size for a file. /// /// The ID of the file. /// A cancellation token to monitor for cancellation requests. /// A task that when awaited contains a tuple with the metadata hash and size, or null if the file does not exist. public async Task<(string MetadataHash, long Size)?> GetMetadataHashAndSizeForFile(long fileid, CancellationToken token) { m_selectfilemetadatahashandsizeCommand .SetTransaction(m_rtr) .SetParameterValue("@FileId", fileid); await using var rd = await m_selectfilemetadatahashandsizeCommand.ExecuteReaderAsync(token).ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) return ( rd.ConvertValueToString(1) ?? throw new InvalidOperationException("Metadata hash is null"), rd.ConvertValueToInt64(0) ); return null; } /// /// Gets the hash of a file. /// /// The ID of the file. /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains the hash of the file, or null if the file does not exist. public async Task GetFileHash(long fileid, CancellationToken token) { var r = await m_selectfileHashCommand .SetTransaction(m_rtr) .SetParameterValue("@FileId", fileid) .ExecuteScalarAsync(m_logQueries, token) .ConfigureAwait(false); if (r == null || r == DBNull.Value) return null; return r.ToString(); } public override void Dispose() { this.DisposeAsync().AsTask().Await(); } public override async ValueTask DisposeAsync() { if (!string.IsNullOrWhiteSpace(m_tempDeletedBlockTable)) try { await using (var cmd = m_connection.CreateCommand(m_rtr)) await cmd.ExecuteNonQueryAsync($@"DROP TABLE ""{m_tempDeletedBlockTable}""", default) .ConfigureAwait(false); await m_rtr.CommitAsync().ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "DropTempTableFailed", ex, "Failed to drop temporary table {0}: {1}", m_tempDeletedBlockTable, ex.Message); } await base.DisposeAsync().ConfigureAwait(false); } /// /// Gets the size of the last written DBlock volume. /// /// The cancellation token to monitor for cancellation requests. /// A task that when awaited contains the size of the last written DBlock volume, or -1 if no such volume exists. public async Task GetLastWrittenDBlockVolumeSize(CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); return await cmd.SetCommandAndParameters(@" SELECT ""Size"" FROM ""RemoteVolume"" WHERE ""State"" = @State AND ""Type"" = @Type ORDER BY ""ID"" DESC LIMIT 1 ") .SetParameterValue("@State", RemoteVolumeState.Uploaded.ToString()) .SetParameterValue("@Type", RemoteVolumeType.Blocks.ToString()) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); } /// /// Gets the ID of the previous fileset based on the operation timestamp and current fileset ID. /// /// The command to use for the query. /// The cancellation token to cancel the operation. /// A task that when awaited contains the ID of the previous fileset, or -1 if no such fileset exists. private async Task GetPreviousFilesetID(SqliteCommand cmd, CancellationToken token) { return await GetPreviousFilesetID(cmd, OperationTimestamp, m_filesetId, token) .ConfigureAwait(false); } /// /// Gets the ID of the previous fileset based on the operation timestamp and current fileset ID. /// /// The command to use for the query. /// The timestamp to use for the query. /// The current fileset ID. /// The cancellation token to cancel the operation. /// A task that when awaited contains the ID of the previous fileset, or -1 if no such fileset exists. private async Task GetPreviousFilesetID(SqliteCommand cmd, DateTime timestamp, long filesetid, CancellationToken token) { return await cmd .SetTransaction(m_rtr) .SetCommandAndParameters(@" SELECT ""ID"" FROM ""Fileset"" WHERE ""Timestamp"" < @Timestamp AND ""ID"" != @FilesetId ORDER BY ""Timestamp"" DESC ") .SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(timestamp)) .SetParameterValue("@FilesetId", filesetid) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); } /// /// Gets the count and size of files in the last backup fileset. /// /// The cancellation token to cancel the operation. /// A task that when awaited contains a tuple with the count of files and the total size of files in the last backup fileset. internal async Task> GetLastBackupFileCountAndSize(CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); var lastFilesetId = await cmd.ExecuteScalarInt64Async(@" SELECT ""ID"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC LIMIT 1 ", token) .ConfigureAwait(false); var count = await cmd.SetCommandAndParameters(@" SELECT COUNT(*) FROM ""FileLookup"" INNER JOIN ""FilesetEntry"" ON ""FileLookup"".""ID"" = ""FilesetEntry"".""FileID"" WHERE ""FilesetEntry"".""FilesetID"" = @FilesetId AND ""FileLookup"".""BlocksetID"" NOT IN ( @FolderBlocksetId, @SymlinkBlocksetId ) ") .SetParameterValue("@FilesetId", lastFilesetId) .SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID) .SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); var size = await cmd.SetCommandAndParameters(@" SELECT SUM(""Blockset"".""Length"") FROM ""FileLookup"", ""FilesetEntry"", ""Blockset"" WHERE ""FileLookup"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FileLookup"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FilesetID"" = @FilesetId AND ""FileLookup"".""BlocksetID"" NOT IN ( @FolderBlocksetId, @SymlinkBlocksetId ) ") .SetParameterValue("@FilesetId", lastFilesetId) .SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID) .SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); return new Tuple(count, size); } /// /// Updates the change statistics for the current fileset based on the results of a backup operation. /// /// The results of the backup operation. /// The cancellation token to cancel the operation. /// A task that completes when the change statistics are updated. internal async Task UpdateChangeStatistics(BackupResults results, CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); var prevFileSetId = await GetPreviousFilesetID(cmd, token) .ConfigureAwait(false); await ChangeStatistics.UpdateChangeStatistics(cmd, results, m_filesetId, prevFileSetId, token) .ConfigureAwait(false); } /// /// Populates FilesetEntry table with files from previous fileset, which aren't /// yet part of the new fileset, and which aren't on the (optional) list of deleted paths. /// /// List of deleted paths, or null." /// The cancellation token to cancel the operation. /// A task that completes when the files are appended. public async Task AppendFilesFromPreviousSet(IEnumerable? deleted, CancellationToken token) { await AppendFilesFromPreviousSet(deleted, m_filesetId, -1, OperationTimestamp, token) .ConfigureAwait(false); } /// /// Populates FilesetEntry table with files from previous fileset, which aren't /// yet part of the new fileset, and which aren't on the (optional) list of deleted paths. /// /// List of deleted paths, or null. /// Current file-set ID. /// Source file-set ID. /// If filesetid == -1, used to locate previous file-set. /// The cancellation token to cancel the operation. /// A task that completes when the files are appended. public async Task AppendFilesFromPreviousSet(IEnumerable? deleted, long filesetid, long prevId, DateTime timestamp, CancellationToken token) { await using var cmd = m_connection.CreateCommand(); await using var cmdDelete = m_connection.CreateCommand(); long lastFilesetId = prevId < 0 ? await GetPreviousFilesetID(cmd, timestamp, filesetid, token) .ConfigureAwait(false) : prevId; await cmd.SetTransaction(m_rtr) .SetCommandAndParameters(@" INSERT INTO ""FilesetEntry"" ( ""FilesetID"", ""FileID"", ""Lastmodified"" ) SELECT @CurrentFilesetId AS ""FilesetID"", ""FileID"", ""Lastmodified"" FROM ( SELECT DISTINCT ""FilesetID"", ""FileID"", ""Lastmodified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = @PreviousFilesetId AND ""FileID"" NOT IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = @CurrentFilesetId ) ) ") .SetParameterValue("@CurrentFilesetId", filesetid) .SetParameterValue("@PreviousFilesetId", lastFilesetId) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); if (deleted != null) { await using var tmplist = await TemporaryDbValueList .CreateAsync(this, deleted, token) .ConfigureAwait(false); await ( await cmdDelete.SetTransaction(m_rtr) .SetCommandAndParameters(@" DELETE FROM ""FilesetEntry"" WHERE ""FilesetID"" = @FilesetId AND ""FileID"" IN ( SELECT ""ID"" FROM ""File"" WHERE ""Path"" IN (@Paths) ) ") .SetParameterValue("@FilesetId", filesetid) .ExpandInClauseParameterMssqliteAsync("@Paths", tmplist, token) .ConfigureAwait(false) ) .ExecuteNonQueryAsync(m_logQueries, token) .ConfigureAwait(false); } await m_rtr.CommitAsync(token).ConfigureAwait(false); } /// /// Populates FilesetEntry table with files from previous fileset, which aren't /// yet part of the new fileset, and which aren't excluded by the (optional) exclusion /// predicate. /// /// Optional exclusion predicate (true = exclude file). /// The cancellation token to cancel the operation. /// A task that completes when the files are appended. public async Task AppendFilesFromPreviousSetWithPredicate(Func exclusionPredicate, CancellationToken token) { await AppendFilesFromPreviousSetWithPredicate(exclusionPredicate, m_filesetId, -1, OperationTimestamp, token) .ConfigureAwait(false); } /// /// Populates FilesetEntry table with files from previous fileset, which aren't /// yet part of the new fileset, and which aren't excluded by the (optional) exclusion /// predicate. /// /// Optional exclusion predicate (true = exclude file) /// Current fileset ID /// Source fileset ID /// If prevFileSetId == -1, used to locate previous fileset /// The cancellation token to cancel the operation. /// A task that completes when the files are appended. public async Task AppendFilesFromPreviousSetWithPredicate(Func exclusionPredicate, long fileSetId, long prevFileSetId, DateTime timestamp, CancellationToken token) { if (exclusionPredicate == null) { await AppendFilesFromPreviousSet(null, fileSetId, prevFileSetId, timestamp, token) .ConfigureAwait(false); return; } await using var cmd = m_connection.CreateCommand(); await using var cmdDelete = m_connection.CreateCommand(); long lastFilesetId = prevFileSetId < 0 ? await GetPreviousFilesetID(cmd, timestamp, fileSetId, token) .ConfigureAwait(false) : prevFileSetId; // copy entries from previous file set into a temporary table, except those file IDs already added by the current backup var tempFileSetTable = $"FilesetEntry-{Library.Utility.Utility.GetHexGuid()}"; await cmd .SetTransaction(m_rtr) .SetCommandAndParameters($@" CREATE TEMPORARY TABLE ""{tempFileSetTable}"" AS SELECT ""FileID"", ""Lastmodified"" FROM ( SELECT DISTINCT ""FilesetID"", ""FileID"", ""Lastmodified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = @PreviousFilesetId AND ""FileID"" NOT IN ( SELECT ""FileID"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = @CurrentFilesetId ) ) ") .SetParameterValue("@PreviousFilesetId", lastFilesetId) .SetParameterValue("@CurrentFilesetId", fileSetId) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); // now we need to remove, from the above, any entries that were enumerated by the // UNC-driven backup cmdDelete.SetTransaction(m_rtr) .SetCommandAndParameters($@" DELETE FROM ""{tempFileSetTable}"" WHERE ""FileID"" = @FileId "); // enumerate files from new temporary file set, and remove any entries handled by UNC cmd.SetCommandAndParameters($@" SELECT ""f"".""Path"", ""fs"".""FileID"", ""fs"".""Lastmodified"", COALESCE(""bs"".""Length"", -1) FROM ( SELECT DISTINCT ""FileID"", ""Lastmodified"" FROM ""{tempFileSetTable}"" ) AS ""fs"" LEFT JOIN ""File"" AS ""f"" ON ""fs"".""FileID"" = ""f"".""ID"" LEFT JOIN ""Blockset"" AS ""bs"" ON ""f"".""BlocksetID"" = ""bs"".""ID""; "); await foreach (var row in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) { var path = row.ConvertValueToString(0) ?? throw new Exception("Unexpected null value for path"); var size = row.ConvertValueToInt64(3); if (exclusionPredicate(path, size)) await cmdDelete.SetParameterValue("@FileId", row.ConvertValueToInt64(1)) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); } // now copy the temporary table into the FileSetEntry table await cmd.SetCommandAndParameters($@" INSERT INTO ""FilesetEntry"" ( ""FilesetID"", ""FileID"", ""Lastmodified"" ) SELECT @FilesetId, ""FileID"", ""Lastmodified"" FROM ""{tempFileSetTable}"" ") .SetParameterValue("@FilesetId", fileSetId) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); await m_rtr.CommitAsync(token).ConfigureAwait(false); } /// /// Creates a timestamped backup operation to correctly associate the fileset with the time it was created. /// /// The ID of the fileset volume to update. /// The timestamp of the operation to create. /// The cancellation token to cancel the operation. /// A task that when awaited contains the ID of the created fileset. public override async Task CreateFileset(long volumeid, DateTime timestamp, CancellationToken token) { return m_filesetId = await base.CreateFileset(volumeid, timestamp, token) .ConfigureAwait(false); } /// /// Retrieves the names of temporary fileset volumes that are incomplete. /// /// If true, only the latest incomplete fileset volume will be returned. /// The cancellation token to cancel the operation. /// A task that when awaited contains a list of volume names. public async Task> GetTemporaryFilelistVolumeNames(bool latestOnly, CancellationToken token) { var incompleteFilesetIDs = GetIncompleteFilesets(token).OrderBy(x => x.Value).Select(x => x.Key); if (!await incompleteFilesetIDs.AnyAsync(token).ConfigureAwait(false)) return []; if (latestOnly) incompleteFilesetIDs = new long[] { await incompleteFilesetIDs.LastAsync(token).ConfigureAwait(false) } .ToAsyncEnumerable(); var volumeNames = new List(); await foreach (var filesetID in incompleteFilesetIDs.ConfigureAwait(false)) volumeNames.Add(( await GetRemoteVolumeFromFilesetID(filesetID, token) .ConfigureAwait(false) ).Name); return volumeNames; } /// /// Retrieves the names of remote volumes that are missing index files. /// /// The cancellation token to cancel the operation. /// An asynchronous enumerable of volume names that are missing index files. public async IAsyncEnumerable GetMissingIndexFiles([EnumeratorCancellation] CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr) .SetCommandAndParameters(@" SELECT ""Name"" FROM ""RemoteVolume"" WHERE ""Type"" = @Type AND NOT ""ID"" IN ( SELECT ""BlockVolumeID"" FROM ""IndexBlockLink"" ) AND ""State"" IN (@States) ") .SetParameterValue("@Type", RemoteVolumeType.Blocks.ToString()) .ExpandInClauseParameterMssqlite("@States", [ RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString() ]); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) yield return rd.ConvertValueToString(0) ?? throw new Exception("Unexpected null value for volume name"); } /// /// Moves a block from one volume to another. /// /// The hash of the block to move. /// The size of the block to move. /// The ID of the source volume. /// The ID of the target volume. /// The cancellation token to cancel the operation. /// A task that completes when the block is moved. public async Task MoveBlockToVolume(string blockkey, long size, long sourcevolumeid, long targetvolumeid, CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); var c = await cmd.SetCommandAndParameters(@" UPDATE ""Block"" SET ""VolumeID"" = @NewVolumeId WHERE ""Hash"" = @Hash AND ""Size"" = @Size AND ""VolumeID"" = @PreviousVolumeId ") .SetParameterValue("@NewVolumeId", targetvolumeid) .SetParameterValue("@Hash", blockkey) .SetParameterValue("@Size", size) .SetParameterValue("@PreviousVolumeId", sourcevolumeid) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); if (c != 1) throw new Exception($"Failed to move block {blockkey}:{size} from volume {sourcevolumeid}, count: {c}"); } /// /// Safely deletes a remote volume by checking if it has any associated blocks. /// If it does, an exception is thrown; otherwise, the volume is removed. /// /// The name of the remote volume to delete. /// The cancellation token to cancel the operation. /// A task that completes when the remote volume is safely deleted. /// Thrown if the volume has associated blocks. public async Task SafeDeleteRemoteVolume(string name, CancellationToken token) { var volumeid = await GetRemoteVolumeID(name, token).ConfigureAwait(false); await using var cmd = m_connection.CreateCommand(m_rtr); var c = await cmd.SetCommandAndParameters(@" SELECT COUNT(*) FROM ""Block"" WHERE ""VolumeID"" = @VolumeId ") .SetParameterValue("@VolumeId", volumeid) .ExecuteScalarInt64Async(-1, token) .ConfigureAwait(false); if (c != 0) throw new Exception($"Failed to safe-delete volume {name}, blocks: {c}"); await RemoveRemoteVolume(name, token).ConfigureAwait(false); } /// /// Retrieves the hashes of blocks that are on the blocklist for a given volume. /// /// The name of the volume to check. /// The cancellation token to cancel the operation. /// A task that when awaited contains an array of blocklist hashes. public async Task GetBlocklistHashes(string name, CancellationToken token) { var volumeid = GetRemoteVolumeID(name, token); await using var cmd = m_connection.CreateCommand(m_rtr); // Grab the strings and return as array to avoid concurrent access to the IEnumerable cmd.SetCommandAndParameters(@" SELECT DISTINCT ""Block"".""Hash"" FROM ""Block"" WHERE ""Block"".""VolumeID"" = @VolumeId AND ""Block"".""Hash"" IN ( SELECT ""Hash"" FROM ""BlocklistHash"" ) ") .SetParameterValue("@VolumeId", volumeid); return await cmd.ExecuteReaderEnumerableAsync(token) .Select(x => x.ConvertValueToString(0) ?? throw new Exception("Unexpected null value for blocklist hash")) .ToArrayAsync(cancellationToken: token) .ConfigureAwait(false); } /// /// Retrieves the first path in the database, ordered by length in descending order. /// /// The cancellation token to cancel the operation. /// A task that when awaited contains the first path, or null if no paths exist. public async Task GetFirstPath(CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); var v0 = await cmd.ExecuteScalarAsync(@" SELECT ""Path"" FROM ""File"" ORDER BY LENGTH(""Path"") DESC LIMIT 1 ", token) .ConfigureAwait(false); if (v0 == null || v0 == DBNull.Value) return null; return v0.ToString(); } /// /// Retrieves the change journal data for file set. /// /// The Fileset-ID. /// An asynchronous enumerable of USN journal data entries. public async IAsyncEnumerable GetChangeJournalData(long fileSetId, [EnumeratorCancellation] CancellationToken token) { var data = new List(); await using var cmd = m_connection.CreateCommand(@" SELECT ""VolumeName"", ""JournalID"", ""NextUSN"", ""ConfigHash"" FROM ""ChangeJournalData"" WHERE ""FilesetID"" = @FilesetId ") .SetTransaction(m_rtr) .SetParameterValue("@FilesetId", fileSetId); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) { yield return new Interface.USNJournalDataEntry { Volume = rd.ConvertValueToString(0), JournalId = rd.ConvertValueToInt64(1), NextUsn = rd.ConvertValueToInt64(2), ConfigHash = rd.ConvertValueToString(3) }; } } /// /// Adds NTFS change journal data for file set and volume. /// /// Data to add. /// The cancellation token to cancel the operation. /// A task that completes when the data is added. /// Thrown if unable to add change journal entry. public async Task CreateChangeJournalData(IEnumerable data, CancellationToken token) { foreach (var entry in data) { await using var cmd = m_connection.CreateCommand(m_rtr); var c = await cmd.SetCommandAndParameters(@" INSERT INTO ""ChangeJournalData"" ( ""FilesetID"", ""VolumeName"", ""JournalID"", ""NextUSN"", ""ConfigHash"" ) VALUES ( @FilesetId, @VolumeName, @JournalId, @NextUsn, @ConfigHash ); ") .SetParameterValue("@FilesetId", m_filesetId) .SetParameterValue("@VolumeName", entry.Volume) .SetParameterValue("@JournalId", entry.JournalId) .SetParameterValue("@NextUsn", entry.NextUsn) .SetParameterValue("@ConfigHash", entry.ConfigHash) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); if (c != 1) throw new Exception("Unable to add change journal entry"); } await m_rtr.CommitAsync(token: token).ConfigureAwait(false); } /// /// Adds NTFS change journal data for file set and volume. /// /// Data to add. /// Existing file set to update. /// The cancellation token to cancel the operation. /// A task that completes when the data is added. public async Task UpdateChangeJournalData(IEnumerable data, long fileSetId, CancellationToken token) { foreach (var entry in data) { await using var cmd = m_connection.CreateCommand(); await cmd.SetCommandAndParameters(@" UPDATE ""ChangeJournalData"" SET ""NextUSN"" = @NextUsn WHERE ""FilesetID"" = @FilesetId AND ""VolumeName"" = @VolumeName AND ""JournalID"" = @JournalId; ") .SetTransaction(m_rtr) .SetParameterValue("@NextUsn", entry.NextUsn) .SetParameterValue("@FilesetId", fileSetId) .SetParameterValue("@VolumeName", entry.Volume) .SetParameterValue("@JournalId", entry.JournalId) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); } await m_rtr.CommitAsync(token: token).ConfigureAwait(false); } /// /// Checks if a blocklist hash is known. /// /// The hash to check. /// The cancellation token to cancel the operation. /// A task that when awaited returns true if the hash is known, false otherwise. public async Task IsBlocklistHashKnown(string hash, CancellationToken token) { var res = await m_getfirstfilesetwithblockinblockset .SetTransaction(m_rtr) .SetParameterValue("@Hash", hash) .ExecuteScalarInt64Async(token) .ConfigureAwait(false); if (res != -1 && res != m_filesetId) return true; else return !m_blocklistHashes.Add(hash); } } }