using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Duplicati.Library.SQLiteHelper; using System.Runtime.CompilerServices; #nullable enable namespace Duplicati.Library.Main.Database.Sync; /// /// Local database for the sync operation. /// /// /// The sync database holds three distinct kinds of state, kept separate by purpose: /// /// RemoteInventory - the observed-remote cache: a snapshot of what a /// call returned. It is the diff baseline /// against the local source and is refreshed by listing. It is NOT written to as /// part of recording operation intent. /// PendingOperation - the intent journal: rows are inserted BEFORE an /// upload/update/delete is attempted and removed once it completes or is reconciled. /// Leftover rows on resume make a crash recoverable: they say exactly what was being /// attempted, so the program can reconcile against a fresh listing instead of /// silently dropping state. /// RemoteOperation - an append-only audit log of completed backend calls, /// purged by time. Distinct from PendingOperation: this records what happened, /// PendingOperation records what is about to happen. /// /// public class LocalSyncDatabase : IDisposable, IBackendManagerDatabase { private readonly SqliteConnection m_connection; /// /// The threshold for automatic purging of the RemoteOperation audit log. /// private static readonly TimeSpan AuditLogPurgeAge = TimeSpan.FromDays(30); /// /// The minimum interval between automatic purges of the RemoteOperation /// audit log. Purging on every logged operation would be a full-table scan per /// call; throttling to a coarse interval keeps that cost amortized. The /// RemoteOperationTimestamp index makes the purge itself cheap when it /// does run. /// private static readonly TimeSpan AuditLogPurgeInterval = TimeSpan.FromHours(1); /// /// The UTC time the audit-log purge last ran, used to throttle purges to at most /// once per . Defaulted to DateTime.MinValue /// so the first log call does not immediately trigger a purge. /// private DateTime m_lastAuditPurgeUtc = DateTime.MinValue; public LocalSyncDatabase(string path) { var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir!); m_connection = SQLiteLoader.LoadConnection(path); try { DatabaseUpgrader.UpgradeDatabase(m_connection, path, typeof(DatabaseSchemaMarker)); } catch { m_connection.Dispose(); throw; } } /// /// Runs the supplied action inside a single explicit SQLite transaction, so the /// writes it contains commit atomically. Used to make an operation's "mark /// complete" writes (inventory update + pending-operation removal) all-or-nothing, /// preventing a crash from leaving the inventory and intent journal mutually /// inconsistent. /// /// The writes to perform inside the transaction. /// Cancellation token to monitor for cancellation requests. /// A task that completes when the transaction has been committed. public async Task ExecuteInTransactionAsync(Func action, CancellationToken cancellationToken) { if (action == null) throw new ArgumentNullException(nameof(action)); await using var tx = await m_connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { await action(cancellationToken).ConfigureAwait(false); await tx.CommitAsync(cancellationToken).ConfigureAwait(false); } catch { await tx.RollbackAsync(CancellationToken.None).ConfigureAwait(false); throw; } } /// /// Removes every row from the PendingOperation intent journal. /// /// /// This should only be called once leftover in-flight operations have been /// reconciled against a fresh remote listing. Clearing without reconciling /// discards the recoverability that the intent journal exists to provide. /// public async Task ClearPendingOperationsAsync(CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"DELETE FROM ""PendingOperation"";"; await cmd.ExecuteNonQueryAsync(cancellationToken); } /// /// Inserts or updates a row in the RemoteInventory observed-state cache. /// public async Task UpsertInventoryAsync(string relativePath, long size, DateTime lastModified, string? contentHash, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @" INSERT INTO ""RemoteInventory"" (""RelativePath"", ""Size"", ""LastModified"", ""ContentHash"", ""LastVerified"") VALUES (@path, @size, @modified, @hash, @verified) ON CONFLICT(""RelativePath"") DO UPDATE SET ""Size""=excluded.""Size"", ""LastModified""=excluded.""LastModified"", ""ContentHash""=excluded.""ContentHash"", ""LastVerified""=excluded.""LastVerified""; "; cmd.Parameters.AddWithValue("@path", relativePath); cmd.Parameters.AddWithValue("@size", size); cmd.Parameters.AddWithValue("@modified", lastModified.ToUniversalTime().ToString("O")); cmd.Parameters.AddWithValue("@hash", (object?)contentHash ?? DBNull.Value); cmd.Parameters.AddWithValue("@verified", DateTime.UtcNow.ToString("O")); await cmd.ExecuteNonQueryAsync(cancellationToken); } /// /// Records an in-flight operation in the PendingOperation intent journal, /// or updates the Attempts counter of an existing row for the same path. /// /// The relative path the operation targets on the remote destination. /// The intended operation. /// The expected size of the file, if known, for verification on resume. /// The expected hash of the file, if known, for verification on resume. /// Cancellation token to monitor for cancellation requests. public async Task UpsertPendingOperationAsync(string path, SyncOperation operation, long? size, string? hash, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @" INSERT INTO ""PendingOperation"" (""Path"", ""Operation"", ""Size"", ""Hash"", ""StartedAt"", ""Attempts"") VALUES (@path, @operation, @size, @hash, @startedAt, 0) ON CONFLICT(""Path"") DO UPDATE SET ""Operation""=excluded.""Operation"", ""Size""=excluded.""Size"", ""Hash""=excluded.""Hash"", ""StartedAt""=excluded.""StartedAt"", ""Attempts""=""PendingOperation"".""Attempts"" + 1; "; cmd.Parameters.AddWithValue("@path", path); cmd.Parameters.AddWithValue("@operation", operation.ToString()); cmd.Parameters.AddWithValue("@size", (object?)size ?? DBNull.Value); cmd.Parameters.AddWithValue("@hash", (object?)hash ?? DBNull.Value); cmd.Parameters.AddWithValue("@startedAt", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow)); await cmd.ExecuteNonQueryAsync(cancellationToken); } /// /// Removes a row from the RemoteInventory observed-state cache. /// public async Task RemoveInventoryAsync(string relativePath, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"DELETE FROM ""RemoteInventory"" WHERE ""RelativePath"" = @path;"; cmd.Parameters.AddWithValue("@path", relativePath); await cmd.ExecuteNonQueryAsync(cancellationToken); } /// /// Removes a row from the PendingOperation intent journal, indicating /// that the in-flight operation has completed or been reconciled. /// public async Task RemovePendingOperationAsync(string path, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"DELETE FROM ""PendingOperation"" WHERE ""Path"" = @path;"; cmd.Parameters.AddWithValue("@path", path); await cmd.ExecuteNonQueryAsync(cancellationToken); } /// /// Gets a single inventory item by relative path, or null if no such row exists. /// public async Task GetInventoryItemAsync(string relativePath, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"SELECT ""RelativePath"", ""Size"", ""LastModified"", ""ContentHash"" FROM ""RemoteInventory"" WHERE ""RelativePath"" = @path;"; cmd.Parameters.AddWithValue("@path", relativePath); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { return new InventoryItem { RelativePath = reader.GetString(0), Size = reader.GetInt64(1), LastModified = DateTime.Parse(reader.GetString(2)).ToUniversalTime(), ContentHash = await reader.IsDBNullAsync(3, cancellationToken) ? null : reader.GetString(3) }; } return null; } /// /// Enumerates the inventory items that are direct children of the given folder. /// /// /// The folder is addressed by its relative path using '/' as the separator, /// matching the convention used throughout the sync handler. An empty (or null) /// denotes the backend root and yields the top-level /// entries (relative paths containing no '/'). A non-empty folder yields entries /// whose relative path starts with "{folder}/" and contain no further '/'. /// Sub-folder entries are NOT included: only file-shaped inventory rows are /// returned, since the sync handler manages folders implicitly via /// . This is the per-folder /// building block the folder-by-folder sync uses to read the local-database diff /// baseline under without ever /// materializing the whole inventory into memory. /// /// The relative path of the folder to list, or null/empty for the backend root. /// Cancellation token to monitor for cancellation requests. /// An async stream of rows that are direct children of the folder. public async IAsyncEnumerable GetInventoryItemsInFolderAsync(string? folder, [EnumeratorCancellation] CancellationToken cancellationToken) { // The query selects inventory rows that are direct children of the folder. // For the root (empty folder) a direct child has no '/' in its path. For a // nested folder a direct child's path is exactly "{folder}/{name}" with no // further '/'. // // The nested-folder branch uses a sargable prefix scan so SQLite can use the // UNIQUE index on "RelativePath" for a range scan instead of a full table // scan: it matches "RelativePath LIKE @prefix || '%'" with the LIKE // meta-characters in the prefix escaped and ESCAPE '\' set. The "no further // '/'" check (which excludes sub-folder rows) is applied as a filter over the // candidate range rather than as a substr()-based left-prefix comparison, // which would have defeated the index. This is called once per folder in the // hot sync loop, so keeping it index-backed is important for large trees. // The root branch is inherently a scan (it wants all rows without a '/'), but // the UNIQUE index is still used for an index-only scan. using var cmd = m_connection.CreateCommand(); if (string.IsNullOrEmpty(folder)) { // Root: direct children have no '/'. cmd.CommandText = @"SELECT ""RelativePath"", ""Size"", ""LastModified"", ""ContentHash"" FROM ""RemoteInventory"" WHERE ""RelativePath"" NOT LIKE '%/%';"; } else { // Nested: path starts with "{folder}/" and has no further '/' after the // prefix. Escape LIKE meta-characters in the prefix so a relative path // containing '%', '_', or '\' is matched literally. var prefix = folder + "/"; var escapedPrefix = EscapeLikePattern(prefix); cmd.CommandText = @" SELECT ""RelativePath"", ""Size"", ""LastModified"", ""ContentHash"" FROM ""RemoteInventory"" WHERE ""RelativePath"" LIKE @prefix || '%' ESCAPE '\' AND instr(substr(""RelativePath"", @prefixlen + 1), '/') = 0;"; cmd.Parameters.AddWithValue("@prefix", escapedPrefix); cmd.Parameters.AddWithValue("@prefixlen", prefix.Length); } await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { yield return new InventoryItem { RelativePath = reader.GetString(0), Size = reader.GetInt64(1), LastModified = DateTime.Parse(reader.GetString(2)).ToUniversalTime(), ContentHash = await reader.IsDBNullAsync(3, cancellationToken).ConfigureAwait(false) ? null : reader.GetString(3) }; } } /// /// Escapes the LIKE meta-characters (%, _) and the escape /// character (\) in a pattern so it is matched literally when used with /// ESCAPE '\'. This lets a caller-supplied relative path (which may /// contain those characters) be used safely as the fixed prefix of a sargable /// LIKE @prefix || '%' range scan without becoming a wildcard. /// /// The pattern to escape. /// The escaped pattern, suitable for use with ESCAPE '\'. private static string EscapeLikePattern(string pattern) { // Order matters: escape the escape character first so escaping the // meta-characters does not itself introduce new escapes. return pattern.Replace(@"\", @"\\").Replace("%", @"\%").Replace("_", @"\_"); } /// /// Gets a single pending operation by path, or null if no such row exists. /// public async Task GetPendingOperationAsync(string path, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"SELECT ""ID"", ""Path"", ""Operation"", ""Size"", ""Hash"", ""StartedAt"", ""Attempts"" FROM ""PendingOperation"" WHERE ""Path"" = @path;"; cmd.Parameters.AddWithValue("@path", path); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { return ReadPendingOperation(reader, cancellationToken); } return null; } /// /// A single row in the RemoteInventory observed-state cache. /// public sealed record InventoryItem { public required string RelativePath { get; set; } = ""; public required long Size { get; set; } public required DateTime LastModified { get; set; } public required string? ContentHash { get; set; } } /// /// A single row in the PendingOperation intent journal. /// public sealed record PendingOperationEntry { public required long ID { get; set; } public required string Path { get; set; } = ""; public required SyncOperation Operation { get; set; } public required long? Size { get; set; } public required string? Hash { get; set; } public required DateTime StartedAt { get; set; } public required int Attempts { get; set; } } /// /// Returns true if the RemoteInventory observed-state cache has any rows. /// public async Task HasAnyInventoryAsync(CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"SELECT COUNT(*) FROM ""RemoteInventory"";"; return Convert.ToInt32(await cmd.ExecuteScalarAsync(cancellationToken)) > 0; } /// /// Returns true if the PendingOperation intent journal has any rows, /// i.e. there are in-flight operations left over from a previous run that /// should be reconciled before proceeding. /// public async Task HasAnyPendingOperationsAsync(CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"SELECT COUNT(*) FROM ""PendingOperation"";"; return Convert.ToInt32(await cmd.ExecuteScalarAsync(cancellationToken)) > 0; } /// /// Enumerates every row in the RemoteInventory observed-state cache. /// public async IAsyncEnumerable GetInventoryAsync([EnumeratorCancellation] CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"SELECT ""RelativePath"", ""Size"", ""LastModified"", ""ContentHash"" FROM ""RemoteInventory"";"; await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { yield return new InventoryItem { RelativePath = reader.GetString(0), Size = reader.GetInt64(1), LastModified = DateTime.Parse(reader.GetString(2)).ToUniversalTime(), ContentHash = await reader.IsDBNullAsync(3, cancellationToken) ? null : reader.GetString(3) }; } } /// /// Enumerates every row in the PendingOperation intent journal, for /// reconciliation against a fresh remote listing on resume. /// public async IAsyncEnumerable GetPendingOperationsAsync([EnumeratorCancellation] CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"SELECT ""ID"", ""Path"", ""Operation"", ""Size"", ""Hash"", ""StartedAt"", ""Attempts"" FROM ""PendingOperation"";"; await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { yield return ReadPendingOperation(reader, cancellationToken); } } /// /// Logs a completed backend operation to the append-only RemoteOperation audit log, /// purging entries older than 30 days. /// public async Task LogRemoteOperationAsync(string operation, string path, string? data, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @" INSERT INTO ""RemoteOperation"" (""Timestamp"", ""Operation"", ""Path"", ""Data"") VALUES (@timestamp, @operation, @path, @data); "; cmd.Parameters.AddWithValue("@timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow)); cmd.Parameters.AddWithValue("@operation", operation); cmd.Parameters.AddWithValue("@path", path); cmd.Parameters.AddWithValue("@data", (object?)data ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(cancellationToken); // Throttle the audit-log purge so we don't run a DELETE on every logged // operation. The RemoteOperationTimestamp index makes the purge itself cheap // when it does run; the throttle amortizes it to at most once per interval. var nowUtc = DateTime.UtcNow; if (nowUtc - m_lastAuditPurgeUtc >= AuditLogPurgeInterval) { m_lastAuditPurgeUtc = nowUtc; await PurgeLogDataAsync(nowUtc - AuditLogPurgeAge, cancellationToken); } } /// /// Purges audit-log entries older than the given threshold. /// public async Task PurgeLogDataAsync(DateTime threshold, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = @"DELETE FROM ""RemoteOperation"" WHERE ""Timestamp"" < @threshold;"; cmd.Parameters.AddWithValue("@threshold", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(threshold)); await cmd.ExecuteNonQueryAsync(cancellationToken); } /// /// A handle to a temporary table created on the sync database. Disposing it drops /// the table. Used by the sync handler to hold large path/size lists off the heap /// on memory-constrained devices instead of materializing them in process memory. /// public sealed class TempTable : IAsyncDisposable { private readonly SqliteConnection m_connection; private readonly string m_name; private bool m_disposed; /// /// The name of the temporary table, suitable for use in SQL. /// public string Name => m_name; internal TempTable(SqliteConnection connection, string name) { m_connection = connection; m_name = name; } /// public async ValueTask DisposeAsync() { if (m_disposed) return; m_disposed = true; try { using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@"DROP TABLE IF EXISTS ""{m_name}"";"; await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } catch { // Best-effort cleanup; the temp table lives only for this run. } } } /// /// Creates a temporary table with the given column definition and returns a handle /// that drops it on dispose. The caller owns the table's lifetime. /// /// The body of the CREATE TABLE statement, e.g. "RelativePath TEXT NOT NULL". /// Cancellation token to monitor for cancellation requests. /// A handle. public async Task CreateTempTableAsync(string columnDefinition, CancellationToken cancellationToken) { var name = $"SyncTemp-{Library.Utility.Utility.GetHexGuid()}"; using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@"CREATE TEMPORARY TABLE ""{name}"" ({columnDefinition});"; await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); return new TempTable(m_connection, name); } /// /// Inserts rows into the four-column LocalFiles temp table in chunks, mirroring the /// chunked-insert approach so large file sets do /// not exceed SQLite's parameter limit. The caller drains its streaming source into a /// bounded buffer and flushes via this method so the whole local file set is never /// resident in process memory at once. /// /// The temp table to insert into. Must have the columns RelativePath, AbsolutePath, Size, LastModified. /// The (relativePath, absolutePath, size, modifiedUtc) rows to insert. /// Cancellation token to monitor for cancellation requests. public async Task InsertLocalFilesAsync(string tableName, IEnumerable<(string RelativePath, string AbsolutePath, long Size, DateTime ModifiedUtc)> values, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); foreach (var slice in values.Chunk(Local.LocalDatabase.CHUNK_SIZE)) { var parameterFragments = slice.Select((_, i) => { var n = Library.Utility.Utility.FormatInvariantValue(i); return $"(@p{n}, @a{n}, @s{n}, @m{n})"; }).ToArray(); // INSERT OR IGNORE so the UNIQUE constraint on RelativePath deduplicates: the // first row seen for a path wins and later duplicates are silently skipped. The // table is expected to have a UNIQUE constraint on RelativePath for this to apply. var sql = $@"INSERT OR IGNORE INTO ""{tableName}"" (""RelativePath"", ""AbsolutePath"", ""Size"", ""LastModified"") VALUES {string.Join(", ", parameterFragments)};"; cmd.SetCommandAndParameters(sql); for (int i = 0; i < slice.Length; i++) { var n = Library.Utility.Utility.FormatInvariantValue(i); cmd.SetParameterValue($"@p{n}", slice[i].RelativePath); cmd.SetParameterValue($"@a{n}", slice[i].AbsolutePath); cmd.SetParameterValue($"@s{n}", slice[i].Size); cmd.SetParameterValue($"@m{n}", slice[i].ModifiedUtc.ToString("O")); } await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } } /// /// A row from the computed sync plan, describing one local file and the operation /// to perform against the remote destination. /// public sealed record SyncPlanRow { /// The relative path on the remote destination. public required string RelativePath { get; set; } /// The absolute path of the local source file to upload from. public required string AbsolutePath { get; set; } /// The size of the local source file. public required long Size { get; set; } /// The last-modified time (UTC) of the local source file. public required DateTime LastModifiedUtc { get; set; } /// The operation to perform. public required SyncOperation Operation { get; set; } /// /// The content hash from the remote inventory, when present. Only populated for /// rows when the remote inventory actually /// stored a hash; null otherwise. /// public required string? RemoteContentHash { get; set; } /// /// True when this update row was emitted only because a hash re-check was /// requested (size and mtime are unchanged but a remote hash exists). The /// caller must compute the local hash and skip the upload if it matches the /// ; otherwise it performs a real update. /// public bool HashRecheckOnly { get; set; } } /// /// Streams the upload portion of the sync plan: local files that are not present in /// the remote inventory. This is the LEFT JOIN ... WHERE remote IS NULL half /// of the diff, computed in the database so neither side has to be resident in memory. /// /// The temp table populated with local entries. /// Cancellation token to monitor for cancellation requests. /// An async stream of rows. public async IAsyncEnumerable GetUploadPlanAsync(string localFilesTable, [EnumeratorCancellation] CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@" SELECT lf.""RelativePath"", lf.""AbsolutePath"", lf.""Size"", lf.""LastModified"" FROM ""{localFilesTable}"" lf LEFT JOIN ""RemoteInventory"" ri ON ri.""RelativePath"" = lf.""RelativePath"" WHERE ri.""RelativePath"" IS NULL ORDER BY lf.""RelativePath""; "; await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { yield return new SyncPlanRow { RelativePath = reader.GetString(0), AbsolutePath = reader.GetString(1), Size = reader.GetInt64(2), LastModifiedUtc = DateTime.Parse(reader.GetString(3)).ToUniversalTime(), Operation = SyncOperation.Upload, RemoteContentHash = null }; } } /// /// Streams the update portion of the sync plan: local files that are present in /// the remote inventory but whose size or last-modified time differs. When /// is set, rows whose size and mtime are unchanged /// but whose remote inventory carries a content hash are also emitted, so the /// caller can re-check the hash against the local file's current content. A /// listing rebuild stores a null ContentHash, so those rows are naturally /// excluded unless the inventory actually has a hash to compare against. /// /// The temp table populated with local entries. /// Whether hash-based comparison is requested by the user. /// Cancellation token to monitor for cancellation requests. /// An async stream of rows. public async IAsyncEnumerable GetUpdatePlanAsync(string localFilesTable, bool verifyHash, [EnumeratorCancellation] CancellationToken cancellationToken) { // The hash-recheck branch emits rows where size is unchanged and the local // mtime is not newer than the remote (i.e. remote >= local), matching the // original in-memory diff which only flagged a hash mismatch when the size was // equal and the local file was NOT newer than the remote. Including the // local-older case here is important: a backend may stamp the remote file with // a later mtime than the local source, and the original code still hash-checked // such rows. These rows may turn out to be unchanged once the local hash is // computed; the caller is responsible for skipping them in that case. var hashClause = verifyHash ? @" OR (ri.""Size"" = lf.""Size"" AND ri.""LastModified"" >= lf.""LastModified"" AND ri.""ContentHash"" IS NOT NULL)" : string.Empty; using var cmd = m_connection.CreateCommand(); // HashRecheckOnly is true when the row was emitted solely because of the hash // clause: size is equal AND the remote mtime is not older than the local mtime // (so neither the size-differ nor the local-newer clauses matched). The caller // uses this flag to decide whether to compute the local hash before uploading. cmd.CommandText = $@" SELECT lf.""RelativePath"", lf.""AbsolutePath"", lf.""Size"", lf.""LastModified"", ri.""ContentHash"", (ri.""Size"" = lf.""Size"" AND ri.""LastModified"" >= lf.""LastModified"") AS ""HashOnly"" FROM ""{localFilesTable}"" lf INNER JOIN ""RemoteInventory"" ri ON ri.""RelativePath"" = lf.""RelativePath"" WHERE ri.""Size"" <> lf.""Size"" OR ri.""LastModified"" < lf.""LastModified"" {hashClause} ORDER BY lf.""RelativePath""; "; await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { yield return new SyncPlanRow { RelativePath = reader.GetString(0), AbsolutePath = reader.GetString(1), Size = reader.GetInt64(2), LastModifiedUtc = DateTime.Parse(reader.GetString(3)).ToUniversalTime(), Operation = SyncOperation.Update, RemoteContentHash = await reader.IsDBNullAsync(4, cancellationToken).ConfigureAwait(false) ? null : reader.GetString(4), HashRecheckOnly = reader.GetBoolean(5) }; } } /// /// Streams the delete portion of the sync plan: remote inventory entries that have /// no matching local file and no outstanding pending-operation intent row. Pending /// uploads/updates are excluded here so a delete never races its own upload; those /// paths are reconciled via the intent journal instead. /// /// The temp table populated with local entries. /// Cancellation token to monitor for cancellation requests. /// An async stream of rows (with and empty ). public async IAsyncEnumerable GetDeletePlanAsync(string localFilesTable, [EnumeratorCancellation] CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@" SELECT ri.""RelativePath"", ri.""Size"", ri.""LastModified"" FROM ""RemoteInventory"" ri LEFT JOIN ""{localFilesTable}"" lf ON lf.""RelativePath"" = ri.""RelativePath"" LEFT JOIN ""PendingOperation"" po ON po.""Path"" = ri.""RelativePath"" WHERE lf.""RelativePath"" IS NULL AND po.""Path"" IS NULL ORDER BY ri.""RelativePath""; "; await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { yield return new SyncPlanRow { RelativePath = reader.GetString(0), AbsolutePath = string.Empty, Size = reader.GetInt64(1), LastModifiedUtc = DateTime.Parse(reader.GetString(2)).ToUniversalTime(), Operation = SyncOperation.Delete, RemoteContentHash = null }; } } /// /// Returns the count of rows in the given temp table, for plan logging. /// public async Task CountRowsAsync(string tableName, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@"SELECT COUNT(*) FROM ""{tableName}"";"; return Convert.ToInt64(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), null); } /// /// Counts the upload portion of the sync plan: local files absent from the remote /// inventory. Used for the plan summary log without materializing the rows. /// public async Task CountUploadPlanAsync(string localFilesTable, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@" SELECT COUNT(*) FROM ""{localFilesTable}"" lf LEFT JOIN ""RemoteInventory"" ri ON ri.""RelativePath"" = lf.""RelativePath"" WHERE ri.""RelativePath"" IS NULL; "; return Convert.ToInt64(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), null); } /// /// Counts the update portion of the sync plan, mirroring . /// public async Task CountUpdatePlanAsync(string localFilesTable, bool verifyHash, CancellationToken cancellationToken) { var hashClause = verifyHash ? @" OR (ri.""Size"" = lf.""Size"" AND ri.""LastModified"" >= lf.""LastModified"" AND ri.""ContentHash"" IS NOT NULL)" : string.Empty; using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@" SELECT COUNT(*) FROM ""{localFilesTable}"" lf INNER JOIN ""RemoteInventory"" ri ON ri.""RelativePath"" = lf.""RelativePath"" WHERE ri.""Size"" <> lf.""Size"" OR ri.""LastModified"" < lf.""LastModified"" {hashClause}; "; return Convert.ToInt64(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), null); } /// /// Counts the delete portion of the sync plan, mirroring . /// public async Task CountDeletePlanAsync(string localFilesTable, CancellationToken cancellationToken) { using var cmd = m_connection.CreateCommand(); cmd.CommandText = $@" SELECT COUNT(*) FROM ""RemoteInventory"" ri LEFT JOIN ""{localFilesTable}"" lf ON lf.""RelativePath"" = ri.""RelativePath"" LEFT JOIN ""PendingOperation"" po ON po.""Path"" = ri.""RelativePath"" WHERE lf.""RelativePath"" IS NULL AND po.""Path"" IS NULL; "; return Convert.ToInt64(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), null); } /// /// Maps a backup-flavored onto the sync intent journal. /// /// /// The interface was designed around the backup /// database's rich volume state machine. Sync only cares about two of those states, /// so this method translates the relevant ones into intent /// rows and ignores the states that have no sync meaning. /// private static SyncOperation? MapToSyncOperation(RemoteVolumeState state) => state switch { RemoteVolumeState.Uploading => SyncOperation.Upload, RemoteVolumeState.Deleting => SyncOperation.Delete, // Uploaded/Verified/Temporary/Deleted are backup-internal states that have // no meaning for the sync intent journal; the sync handler records its own // intent rows directly and does not rely on these. _ => null }; /// /// Updates the intent journal in response to a backend-manager-flushed volume update. /// Maps the backup onto a /// where possible; states with no sync meaning are no-ops. /// public Task UpdateRemoteVolumeAsync(string name, RemoteVolumeState state, long size, string? hash, CancellationToken cancellationToken) { var op = MapToSyncOperation(state); if (!op.HasValue) return Task.CompletedTask; return UpsertPendingOperationAsync(name, op.Value, size <= 0 ? (long?)null : size, hash, cancellationToken); } /// /// Updates the intent journal in response to a backend-manager-flushed volume update. /// The backup-specific parameters (, /// , ) have no sync /// meaning and are ignored. /// public Task UpdateRemoteVolumeAsync(string name, RemoteVolumeState state, long size, string? hash, bool suppressCleanup, TimeSpan deleteGraceTime, bool? setArchived, CancellationToken cancellationToken) => UpdateRemoteVolumeAsync(name, state, size, hash, cancellationToken); /// /// Renames a remote file in both the observed-state cache and the intent journal, /// preserving the in-flight operation (if any) under the new name. /// public async Task RenameRemoteFileAsync(string oldname, string newname, CancellationToken cancellationToken) { var item = await GetInventoryItemAsync(oldname, cancellationToken); if (item != null) { await RemoveInventoryAsync(oldname, cancellationToken); await UpsertInventoryAsync(newname, item.Size, item.LastModified, item.ContentHash, cancellationToken); } else { // Keep behavior: ensure the old name is gone and a placeholder exists under the new name. await RemoveInventoryAsync(oldname, cancellationToken); await UpsertInventoryAsync(newname, 0, DateTime.UtcNow, null, cancellationToken); } var pending = await GetPendingOperationAsync(oldname, cancellationToken); if (pending != null) { await RemovePendingOperationAsync(oldname, cancellationToken); await UpsertPendingOperationAsync(newname, pending.Operation, pending.Size, pending.Hash, cancellationToken); } } /// /// Removes the given paths from the intent journal. Used by the backend manager /// flush path to drop intent rows once the corresponding operation has been /// confirmed on the remote destination. /// public async Task RemoveRemoteVolumesAsync(IEnumerable names, CancellationToken cancellationToken) { foreach (var name in names) await RemovePendingOperationAsync(name, cancellationToken); } /// /// Commits the current transaction. /// /// /// This is a no-op for the sync database: callers that need atomic multi-statement /// writes use , which manages its own /// transaction. This method exists only to satisfy the /// contract shared with the backup database, /// whose backend-manager flush path calls it after removing deleted volumes. /// public Task CommitAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } /// /// Reads a from the current row of a data reader. /// private static PendingOperationEntry ReadPendingOperation(SqliteDataReader reader, CancellationToken cancellationToken) { return new PendingOperationEntry { ID = reader.GetInt64(0), Path = reader.GetString(1), Operation = (SyncOperation)Enum.Parse(typeof(SyncOperation), reader.GetString(2)), Size = reader.IsDBNull(3) ? null : reader.GetInt64(3), Hash = reader.IsDBNull(4) ? null : reader.GetString(4), StartedAt = Library.Utility.Utility.EPOCH.AddSeconds(reader.GetInt64(5)), Attempts = reader.GetInt32(6) }; } public void Dispose() { m_connection.Dispose(); } }