Files
duplicati/Duplicati/Library/Main/Database/LocalDatabase.cs
T

1865 lines
95 KiB
C#
Raw Normal View History

2025-01-14 19:42:03 +01:00
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
2025-05-12 16:56:41 +02:00
//
// 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
2025-01-14 19:42:03 +01:00
// Software is furnished to do so, subject to the following conditions:
2025-05-12 16:56:41 +02:00
//
// The above copyright notice and this permission notice shall be included in
2025-01-14 19:42:03 +01:00
// all copies or substantial portions of the Software.
2025-05-12 16:56:41 +02:00
//
// 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.
2025-04-03 14:20:00 +02:00
#nullable enable
2024-02-28 15:45:30 +01:00
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
2018-11-14 08:47:01 -02:00
using Duplicati.Library.Modules.Builtin.ResultSerialization;
2019-09-29 20:16:28 -07:00
using Duplicati.Library.Utility;
using Duplicati.Library.Interface;
using Microsoft.Data.Sqlite;
// Expose internal classes to UnitTests, so that Database classes can be tested
[assembly: InternalsVisibleTo("Duplicati.UnitTest")]
namespace Duplicati.Library.Main.Database
{
internal class LocalDatabase : IDisposable
2019-08-05 20:14:05 -04:00
{
/// <summary>
/// The tag used for logging
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(LocalDatabase));
/// <summary>
/// The chunk size for batch operations
/// </summary>
/// <remarks>SQLite has a limit of 999 parameters in a single statement</remarks>
public const int CHUNK_SIZE = 128;
protected readonly SqliteConnection m_connection;
protected readonly long m_operationid = -1;
2025-04-22 14:26:46 +02:00
protected readonly long m_pagecachesize;
2025-01-14 19:42:03 +01:00
private bool m_hasExecutedVacuum;
private readonly SqliteCommand m_updateremotevolumeCommand;
private readonly SqliteCommand m_selectremotevolumesCommand;
private readonly SqliteCommand m_selectremotevolumeCommand;
private readonly SqliteCommand m_removeremotevolumeCommand;
private readonly SqliteCommand m_removedeletedremotevolumeCommand;
private readonly SqliteCommand m_selectremotevolumeIdCommand;
private readonly SqliteCommand m_createremotevolumeCommand;
private readonly SqliteCommand m_selectduplicateRemoteVolumesCommand;
private readonly SqliteCommand m_insertlogCommand;
private readonly SqliteCommand m_insertremotelogCommand;
private readonly SqliteCommand m_insertIndexBlockLink;
private readonly SqliteCommand m_findpathprefixCommand;
private readonly SqliteCommand m_insertpathprefixCommand;
public const long FOLDER_BLOCKSET_ID = -100;
public const long SYMLINK_BLOCKSET_ID = -200;
2013-03-08 22:24:54 +01:00
public DateTime OperationTimestamp { get; private set; }
internal SqliteConnection Connection { get { return m_connection; } }
2019-08-05 20:14:05 -04:00
public bool IsDisposed { get; private set; }
2013-03-08 22:24:54 +01:00
public bool ShouldCloseConnection { get; set; }
protected static async Task<SqliteConnection> CreateConnection(string path, long pagecachesize)
2013-03-08 22:24:54 +01:00
{
2025-03-14 14:34:56 +01:00
path = Path.GetFullPath(path);
if (!Directory.Exists(Path.GetDirectoryName(path)))
2025-04-03 14:20:00 +02:00
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new DirectoryNotFoundException("Path was a root folder."));
2019-08-05 20:14:05 -04:00
var c = await SQLiteHelper.SQLiteLoader.LoadConnection(path, pagecachesize);
2013-03-08 22:24:54 +01:00
try
{
SQLiteHelper.DatabaseUpgrader.UpgradeDatabase(c, path, typeof(DatabaseSchemaMarker));
}
catch
{
//Don't leak database connections when something goes wrong
await c.DisposeAsync();
throw;
}
2019-08-05 20:14:05 -04:00
return c;
}
/// <summary>
/// Formats the string using the invariant culture
/// </summary>
/// <param name="formattable">The formattable string</param>
/// <returns>The formatted string</returns>
public static string FormatInvariant(FormattableString formattable)
=> Library.Utility.Utility.FormatInvariant(formattable);
public static bool Exists(string path)
{
return File.Exists(path);
}
/// <summary>
/// Creates a new database instance and starts a new operation
/// </summary>
/// <param name="path">The path to the database</param>
/// <param name="operation">The name of the operation. If null, continues last operation</param>
2025-04-22 14:26:46 +02:00
/// <param name="shouldclose">Should the connection be closed when this object is disposed</param>
/// <param name="pagecachesize">The page cache size</param>
public LocalDatabase(string path, string operation, bool shouldclose, long pagecachesize)
: this(CreateConnection(path, pagecachesize).Await(), operation)
2013-03-08 22:24:54 +01:00
{
ShouldCloseConnection = shouldclose;
2025-04-22 14:26:46 +02:00
m_pagecachesize = pagecachesize;
}
/// <summary>
/// Creates a new database instance and starts a new operation
/// </summary>
public LocalDatabase(LocalDatabase db)
: this(db.m_connection)
{
2025-03-14 14:34:56 +01:00
OperationTimestamp = db.OperationTimestamp;
m_connection = db.m_connection;
m_operationid = db.m_operationid;
2025-04-22 14:26:46 +02:00
m_pagecachesize = db.m_pagecachesize;
}
2019-08-05 20:14:05 -04:00
/// <summary>
/// Creates a new database instance and starts a new operation
/// </summary>
/// <param name="operation">The name of the operation. If null, continues last operation</param>
public LocalDatabase(SqliteConnection connection, string operation)
: this(connection)
{
2025-03-14 14:34:56 +01:00
OperationTimestamp = DateTime.UtcNow;
2013-03-08 22:24:54 +01:00
m_connection = connection;
2025-03-14 14:34:56 +01:00
if (m_connection.State != ConnectionState.Open)
m_connection.Open();
if (operation != null)
{
using var cmd = m_connection.CreateCommand();
using var transaction = m_connection.BeginTransaction();
cmd.Transaction = transaction;
cmd.CommandText = @"INSERT INTO ""Operation"" (""Description"", ""Timestamp"") VALUES (@Description, @Timestamp); SELECT last_insert_rowid();";
cmd.SetParameterValue("@Description", operation);
cmd.SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(OperationTimestamp));
m_operationid = cmd.ExecuteScalarInt64Async(-1).Await();
}
else
{
// Get last operation
using var cmd = m_connection.CreateCommand();
cmd.CommandText = @"SELECT ""ID"", ""Timestamp"" FROM ""Operation"" ORDER BY ""Timestamp"" DESC LIMIT 1";
using var rd = cmd.ExecuteReader(@"SELECT ""ID"", ""Timestamp"" FROM ""Operation"" ORDER BY ""Timestamp"" DESC LIMIT 1");
if (!rd.Read())
throw new Exception("LocalDatabase does not contain a previous operation.");
m_operationid = rd.ConvertValueToInt64(0);
OperationTimestamp = ParseFromEpochSeconds(rd.ConvertValueToInt64(1));
}
}
2019-08-05 20:14:05 -04:00
private LocalDatabase(SqliteConnection connection)
{
2025-04-03 14:20:00 +02:00
m_connection = connection;
m_insertlogCommand = connection.CreateCommand(@"INSERT INTO ""LogData"" (""OperationID"", ""Timestamp"", ""Type"", ""Message"", ""Exception"") VALUES (@OperationID, @Timestamp, @Type, @Message, @Exception)");
m_insertremotelogCommand = connection.CreateCommand(@"INSERT INTO ""RemoteOperation"" (""OperationID"", ""Timestamp"", ""Operation"", ""Path"", ""Data"") VALUES (@OperationID, @Timestamp, @Operation, @Path, @Data)");
m_updateremotevolumeCommand = connection.CreateCommand(@"UPDATE ""Remotevolume"" SET ""OperationID"" = @OperationID, ""State"" = @State, ""Hash"" = @Hash, ""Size"" = @Size WHERE ""Name"" = @Name");
m_selectremotevolumesCommand = connection.CreateCommand(@"SELECT ""ID"", ""Name"", ""Type"", ""Size"", ""Hash"", ""State"", ""DeleteGraceTime"", ""ArchiveTime"" FROM ""Remotevolume""");
m_selectremotevolumeCommand = connection.CreateCommand(m_selectremotevolumesCommand.CommandText + @" WHERE ""Name"" = @Name");
2025-03-14 14:34:56 +01:00
m_selectduplicateRemoteVolumesCommand = connection.CreateCommand(FormatInvariant($@"SELECT DISTINCT ""Name"", ""State"" FROM ""Remotevolume"" WHERE ""Name"" IN (SELECT ""Name"" FROM ""Remotevolume"" WHERE ""State"" IN ('{RemoteVolumeState.Deleted.ToString()}', '{RemoteVolumeState.Deleting.ToString()}')) AND NOT ""State"" IN ('{RemoteVolumeState.Deleted.ToString()}', '{RemoteVolumeState.Deleting.ToString()}')"));
m_removeremotevolumeCommand = connection.CreateCommand(@"DELETE FROM ""Remotevolume"" WHERE ""Name"" = @Name AND (""DeleteGraceTime"" < @Now OR ""State"" != @State)");
m_removedeletedremotevolumeCommand = connection.CreateCommand(FormatInvariant($@"DELETE FROM ""Remotevolume"" WHERE ""State"" == '{RemoteVolumeState.Deleted.ToString()}' AND (""DeleteGraceTime"" < @Now OR LENGTH(""DeleteGraceTime"") > 12) ")); // >12 is to handle removal of old records that were in ticks
m_selectremotevolumeIdCommand = connection.CreateCommand(@"SELECT ""ID"" FROM ""Remotevolume"" WHERE ""Name"" = @Name");
m_createremotevolumeCommand = connection.CreateCommand(@"INSERT INTO ""Remotevolume"" (""OperationID"", ""Name"", ""Type"", ""State"", ""Size"", ""VerificationCount"", ""DeleteGraceTime"", ""ArchiveTime"") VALUES (@OperationID, @Name, @Type, @State, @Size, @VerificationCount, @DeleteGraceTime, @ArchiveTime); SELECT last_insert_rowid();");
m_insertIndexBlockLink = connection.CreateCommand(@"INSERT INTO ""IndexBlockLink"" (""IndexVolumeID"", ""BlockVolumeID"") VALUES (@IndexVolumeId, @BlockVolumeId)");
m_findpathprefixCommand = connection.CreateCommand(@"SELECT ""ID"" FROM ""PathPrefix"" WHERE ""Prefix"" = @Prefix");
m_insertpathprefixCommand = connection.CreateCommand(@"INSERT INTO ""PathPrefix"" (""Prefix"") VALUES (@Prefix); SELECT last_insert_rowid(); ");
}
2019-08-05 20:14:05 -04:00
/// <summary>
/// Creates a DateTime instance by adding the specified number of seconds to the EPOCH value
/// </summary>
public static DateTime ParseFromEpochSeconds(long seconds)
{
return Library.Utility.Utility.EPOCH.AddSeconds(seconds);
}
public async Task UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string? hash, SqliteTransaction transaction)
2019-08-05 20:14:05 -04:00
{
await UpdateRemoteVolume(name, state, size, hash, false, transaction);
2016-03-24 16:30:19 +01:00
}
public async Task UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string? hash, bool suppressCleanup, SqliteTransaction transaction)
2016-03-24 16:30:19 +01:00
{
await UpdateRemoteVolume(name, state, size, hash, suppressCleanup, new TimeSpan(0), null, transaction);
2016-03-24 16:30:19 +01:00
}
public async Task UpdateRemoteVolume(string name, RemoteVolumeState state, long size, string? hash, bool suppressCleanup, TimeSpan deleteGraceTime, bool? setArchived, SqliteTransaction transaction)
{
m_updateremotevolumeCommand.Transaction = transaction;
m_updateremotevolumeCommand.SetParameterValue("@OperationID", m_operationid);
m_updateremotevolumeCommand.SetParameterValue("@State", state.ToString());
m_updateremotevolumeCommand.SetParameterValue("@Hash", hash);
m_updateremotevolumeCommand.SetParameterValue("@Size", size);
m_updateremotevolumeCommand.SetParameterValue("@Name", name);
var c = await m_updateremotevolumeCommand.ExecuteNonQueryAsync();
2019-09-07 17:16:34 -04:00
if (c != 1)
2019-09-07 17:16:34 -04:00
{
throw new Exception($"Unexpected number of remote volumes detected: {c}!");
}
2016-03-24 16:30:19 +01:00
if (deleteGraceTime.Ticks > 0)
2019-09-07 17:16:34 -04:00
{
using var cmd = m_connection.CreateCommand();
cmd.CommandText = @"UPDATE ""RemoteVolume"" SET ""DeleteGraceTime"" = @DeleteGraceTime WHERE ""Name"" = @Name ";
cmd.Transaction = transaction;
cmd.SetParameterValue("@DeleteGraceTime", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow + deleteGraceTime));
cmd.SetParameterValue("@Name", name);
c = await cmd.ExecuteNonQueryAsync();
if (c != 1)
throw new Exception($"Unexpected number of updates when recording remote volume updates: {c}!");
}
2025-01-29 21:44:06 +01:00
if (setArchived.HasValue)
{
using var cmd = m_connection.CreateCommand();
cmd.CommandText = @"UPDATE ""RemoteVolume"" SET ""ArchiveTime"" = @ArchiveTime WHERE ""Name"" = @Name ";
cmd.Transaction = transaction;
cmd.SetParameterValue("@ArchiveTime", setArchived.Value ? Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow) : 0);
cmd.SetParameterValue("@Name", name);
c = await cmd.ExecuteNonQueryAsync();
if (c != 1)
throw new Exception($"Unexpected number of updates when recording remote volume archive-time updates: {c}!");
2019-09-07 17:16:34 -04:00
}
2016-03-24 16:30:19 +01:00
if (!suppressCleanup && state == RemoteVolumeState.Deleted)
2019-09-07 17:16:34 -04:00
{
await RemoveRemoteVolume(name, transaction);
2019-09-07 17:16:34 -04:00
}
}
public async IAsyncEnumerable<KeyValuePair<long, DateTime>> FilesetTimes()
2019-08-05 20:14:05 -04:00
{
using var cmd = m_connection.CreateCommand();
cmd.CommandText = @"SELECT ""ID"", ""Timestamp"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC";
using var rd = await cmd.ExecuteReaderAsync();
while (rd.Read())
yield return new KeyValuePair<long, DateTime>(rd.ConvertValueToInt64(0), ParseFromEpochSeconds(rd.ConvertValueToInt64(1)).ToLocalTime());
}
public async Task<(string Query, Dictionary<string, object?> Values)> GetFilelistWhereClause(DateTime time, long[] versions, IEnumerable<KeyValuePair<long, DateTime>>? filesetslist = null, bool singleTimeMatch = false)
{
KeyValuePair<long, DateTime>[] filesets;
if (filesetslist != null)
filesets = [.. filesetslist];
else
filesets = await FilesetTimes().ToArrayAsync();
2025-03-14 14:34:56 +01:00
var query = new StringBuilder();
2025-04-03 14:20:00 +02:00
var args = new Dictionary<string, object?>();
if (time.Ticks > 0 || (versions != null && versions.Length > 0))
{
2013-06-20 20:17:10 +02:00
var hasTime = false;
if (time.Ticks > 0)
{
if (time.Kind == DateTimeKind.Unspecified)
throw new Exception("Invalid DateTime given, must be either local or UTC");
query.Append(singleTimeMatch ? @" ""Timestamp"" = @Timestamp" : @" ""Timestamp"" <= @Timestamp");
// Make sure the resolution is the same (i.e. no milliseconds)
args.Add("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(time));
2013-06-20 20:17:10 +02:00
hasTime = true;
}
if (versions != null && versions.Length > 0)
{
2025-03-14 14:34:56 +01:00
var qs = new StringBuilder();
2018-05-12 17:24:33 -07:00
foreach (var v in versions)
{
if (v >= 0 && v < filesets.Length)
{
var argName = "@Fileset" + v;
args.Add(argName, filesets[v].Key);
qs.Append(argName);
qs.Append(',');
}
else
Logging.Log.WriteWarningMessage(LOGTAG, "SkipInvalidVersion", null, "Skipping invalid version: {0}", v);
2018-05-12 17:24:33 -07:00
}
if (qs.Length > 0)
{
2013-06-20 20:17:10 +02:00
if (hasTime)
query.Append(" OR ");
query.Append(@" ""ID"" IN (" + qs.ToString(0, qs.Length - 1) + ")");
}
}
if (query.Length > 0)
{
query.Insert(0, " WHERE ");
}
}
2019-08-05 20:14:05 -04:00
return (query.ToString(), args);
}
public async Task<long> GetRemoteVolumeID(string file, SqliteTransaction? transaction = null)
{
m_selectremotevolumeIdCommand.Transaction = transaction;
m_selectremotevolumeIdCommand.SetParameterValue("@Name", file);
return await m_selectremotevolumeIdCommand.ExecuteScalarInt64Async(-1);
}
public async IAsyncEnumerable<KeyValuePair<string, long>> GetRemoteVolumeIDs(IEnumerable<string> files, SqliteTransaction? transaction = null)
{
using var cmd = m_connection.CreateCommand(@"SELECT ""Name"", ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN (@Name)");
cmd.Transaction = transaction;
using var tmptable = new TemporaryDbValueList(m_connection, transaction, files);
cmd.ExpandInClauseParameter("@Name", tmptable);
using var rd = await cmd.ExecuteReaderAsync();
while (await rd.ReadAsync())
yield return new KeyValuePair<string, long>(rd.ConvertValueToString(0) ?? "", rd.ConvertValueToInt64(1));
}
public async Task<RemoteVolumeEntry> GetRemoteVolume(string file, SqliteTransaction? transaction = null)
{
m_selectremotevolumeCommand.Transaction = transaction;
m_selectremotevolumeCommand.SetParameterValue("@Name", file);
using (var rd = await m_selectremotevolumeCommand.ExecuteReaderAsync())
if (await rd.ReadAsync())
return new RemoteVolumeEntry(
rd.ConvertValueToInt64(0),
2025-04-03 14:20:00 +02:00
rd.ConvertValueToString(1),
rd.ConvertValueToString(4),
rd.ConvertValueToInt64(3, -1),
2025-04-03 14:20:00 +02:00
(RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.ConvertValueToString(2) ?? ""),
(RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.ConvertValueToString(5) ?? ""),
ParseFromEpochSeconds(rd.ConvertValueToInt64(6, 0)),
ParseFromEpochSeconds(rd.ConvertValueToInt64(7, 0))
);
2019-08-05 20:14:05 -04:00
return RemoteVolumeEntry.Empty;
}
public async IAsyncEnumerable<KeyValuePair<string, RemoteVolumeState>> DuplicateRemoteVolumes(SqliteTransaction? transaction)
{
m_selectduplicateRemoteVolumesCommand.Transaction = transaction;
await foreach (var rd in m_selectduplicateRemoteVolumesCommand.ExecuteReaderEnumerableAsync())
{
yield return new KeyValuePair<string, RemoteVolumeState>(
2025-04-03 14:20:00 +02:00
rd.ConvertValueToString(0) ?? throw new Exception("Name was null"),
(RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.ConvertValueToString(1) ?? "")
);
}
}
public async IAsyncEnumerable<RemoteVolumeEntry> GetRemoteVolumes(SqliteTransaction? transaction = null)
{
m_selectremotevolumesCommand.Transaction = transaction;
using var rd = await m_selectremotevolumesCommand.ExecuteReaderAsync();
while (await rd.ReadAsync())
{
yield return new RemoteVolumeEntry(
rd.ConvertValueToInt64(0),
rd.ConvertValueToString(1),
rd.ConvertValueToString(4),
rd.ConvertValueToInt64(3, -1),
(RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.ConvertValueToString(2) ?? ""),
(RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.ConvertValueToString(5) ?? ""),
ParseFromEpochSeconds(rd.ConvertValueToInt64(6, 0)),
ParseFromEpochSeconds(rd.ConvertValueToInt64(7, 0))
);
}
}
/// <summary>
/// Log an operation performed on the remote backend
/// </summary>
/// <param name="operation">The operation performed</param>
/// <param name="path">The path involved</param>
/// <param name="data">Any data relating to the operation</param>
public async Task LogRemoteOperation(string operation, string path, string? data, SqliteTransaction? transaction)
{
m_insertremotelogCommand.Transaction = transaction;
m_insertremotelogCommand.SetParameterValue("@OperationID", m_operationid);
m_insertremotelogCommand.SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
m_insertremotelogCommand.SetParameterValue("@Operation", operation);
m_insertremotelogCommand.SetParameterValue("@Path", path);
m_insertremotelogCommand.SetParameterValue("@Data", data);
await m_insertremotelogCommand.ExecuteNonQueryAsync();
}
/// <summary>
/// Log a debug message
/// </summary>
/// <param name="type">The message type</param>
/// <param name="message">The message</param>
/// <param name="exception">An optional exception</param>
public async Task LogMessage(string type, string message, Exception? exception, SqliteTransaction? transaction)
{
m_insertlogCommand.Transaction = transaction;
m_insertlogCommand.SetParameterValue("@OperationID", m_operationid);
m_insertlogCommand.SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
m_insertlogCommand.SetParameterValue("@Type", type);
m_insertlogCommand.SetParameterValue("@Message", message);
m_insertlogCommand.SetParameterValue("@Exception", exception?.ToString());
await m_insertlogCommand.ExecuteNonQueryAsync();
}
public async Task UnlinkRemoteVolume(string name, RemoteVolumeState state, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand(@"DELETE FROM ""RemoteVolume"" WHERE ""Name"" = @Name AND ""State"" = @State ");
cmd.Transaction = transaction;
cmd.SetParameterValue("@Name", name);
cmd.SetParameterValue("@State", state.ToString());
var c = await cmd.ExecuteNonQueryAsync();
if (c != 1)
throw new Exception($"Unexpected number of remote volumes deleted: {c}, expected {1}");
await transaction.CommitAsync();
}
public async Task RemoveRemoteVolume(string name, SqliteTransaction transaction)
{
await RemoveRemoteVolumes([name], transaction);
}
public async Task RemoveRemoteVolumes(IEnumerable<string> names, SqliteTransaction transaction)
{
if (names == null || !names.Any()) return;
using var deletecmd = m_connection.CreateCommand();
string temptransguid = Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray());
var volidstable = "DelVolSetIds-" + temptransguid;
var blocksetidstable = "DelBlockSetIds-" + temptransguid;
var filesetidstable = "DelFilesetIds-" + temptransguid;
// Create and fill a temp table with the volids to delete. We avoid using too many parameters that way.
deletecmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMP TABLE ""{volidstable}"" (""ID"" INTEGER PRIMARY KEY)"));
deletecmd.SetCommandAndParameters(FormatInvariant($@"INSERT OR IGNORE INTO ""{volidstable}"" SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Name"" IN (@VolumeNames)"))
.ExpandInClauseParameter("@VolumeNames", names.ToArray())
.ExecuteNonQuery();
2025-05-09 11:12:45 +02:00
var volIdsSubQuery = FormatInvariant($@"SELECT ""ID"" FROM ""{volidstable}"" ");
deletecmd.Parameters.Clear();
2019-08-05 20:14:05 -04:00
var bsIdsSubQuery = FormatInvariant(@$"
SELECT DISTINCT ""BlocksetEntry"".""BlocksetID"" FROM ""BlocksetEntry"", ""Block""
WHERE ""BlocksetEntry"".""BlockID"" = ""Block"".""ID"" AND ""Block"".""VolumeID"" IN ({volIdsSubQuery})
UNION ALL
SELECT DISTINCT ""BlocksetID"" FROM ""BlocklistHash""
WHERE ""Hash"" IN (SELECT ""Hash"" FROM ""Block"" WHERE ""VolumeID"" IN ({volIdsSubQuery}))");
// Create a temporary table to cache subquery result, as it might take long (SQLite does not cache at all).
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"CREATE TEMP TABLE ""{blocksetidstable}"" (""ID"" INTEGER PRIMARY KEY)"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"INSERT OR IGNORE INTO ""{blocksetidstable}"" (""ID"") {bsIdsSubQuery}"));
bsIdsSubQuery = FormatInvariant($@"SELECT DISTINCT ""ID"" FROM ""{blocksetidstable}"" ");
deletecmd.Parameters.Clear();
// Create a temp table to associate metadata that is being deleted to a fileset
var metadataFilesetQuery = FormatInvariant($@"SELECT Metadataset.ID, FilesetEntry.FilesetID
FROM Metadataset
INNER JOIN FileLookup ON FileLookup.MetadataID = Metadataset.ID
INNER JOIN FilesetEntry ON FilesetEntry.FileID = FileLookup.ID
WHERE Metadataset.BlocksetID IN ({bsIdsSubQuery})
OR Metadataset.ID IN (SELECT MetadataID FROM FileLookup WHERE BlocksetID IN ({bsIdsSubQuery}))");
var metadataFilesetTable = @"DelMetadataFilesetIds-" + temptransguid;
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"CREATE TEMP TABLE ""{metadataFilesetTable}"" (MetadataID INTEGER PRIMARY KEY, FilesetID INTEGER)"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"INSERT OR IGNORE INTO ""{metadataFilesetTable}"" (MetadataID, FilesetID) {metadataFilesetQuery}"));
// Delete FilesetEntry rows that had their metadata deleted
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM FilesetEntry
WHERE FilesetEntry.FilesetID IN (SELECT DISTINCT FilesetID FROM ""{metadataFilesetTable}"")
AND FilesetEntry.FileID IN (
SELECT FilesetEntry.FileID
FROM FilesetEntry
INNER JOIN FileLookup ON FileLookup.ID = FilesetEntry.FileID
WHERE FileLookup.MetadataID IN (SELECT MetadataID FROM ""{metadataFilesetTable}""))"));
// Delete FilesetEntry rows that had their blocks deleted
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM FilesetEntry WHERE FilesetEntry.FileID IN (
SELECT ID FROM FileLookup
WHERE FileLookup.BlocksetID IN ({bsIdsSubQuery}))"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM FileLookup WHERE FileLookup.MetadataID IN (SELECT MetadataID FROM ""{metadataFilesetTable}"")"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM ""Metadataset"" WHERE ""BlocksetID"" IN ({bsIdsSubQuery})"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM ""FileLookup"" WHERE ""BlocksetID"" IN ({bsIdsSubQuery})"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM ""Blockset"" WHERE ""ID"" IN ({bsIdsSubQuery})"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM ""BlocksetEntry"" WHERE ""BlocksetID"" IN ({bsIdsSubQuery})"));
await deletecmd.ExecuteNonQueryAsync(FormatInvariant($@"DELETE FROM ""BlocklistHash"" WHERE ""BlocklistHash"".""BlocksetID"" IN ({bsIdsSubQuery})"));
// If the volume is a block or index volume, this will update the crosslink table, otherwise nothing will happen
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""IndexBlockLink"" WHERE ""BlockVolumeID"" IN ({volIdsSubQuery}) OR ""IndexVolumeID"" IN ({volIdsSubQuery})"));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Block"" WHERE ""VolumeID"" IN ({volIdsSubQuery})"));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""DeletedBlock"" WHERE ""VolumeID"" IN ({volIdsSubQuery})"));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""ChangeJournalData"" WHERE ""FilesetID"" IN (SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ({volIdsSubQuery}))"));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM FilesetEntry WHERE FilesetID IN (SELECT ID FROM Fileset WHERE VolumeID IN ({volIdsSubQuery}))"));
deletecmd.ExecuteNonQuery(FormatInvariant($@"CREATE TABLE ""{filesetidstable}"" (""ID"" INTEGER PRIMARY KEY)"));
deletecmd.ExecuteNonQuery(FormatInvariant($@"INSERT OR IGNORE INTO ""{filesetidstable}"" SELECT ""ID"" FROM ""Fileset"" WHERE ""VolumeID"" IN ({volIdsSubQuery})"));
// Delete from Fileset if FilesetEntry rows were deleted by related metadata and there are no references in FilesetEntry anymore
deletecmd.ExecuteNonQuery(FormatInvariant($@"INSERT OR IGNORE INTO ""{filesetidstable}"" SELECT ""ID"" FROM ""Fileset"" WHERE ""Fileset"".""ID"" IN
(SELECT DISTINCT ""FilesetID"" FROM ""{metadataFilesetTable}"")
AND ""Fileset"".""ID"" NOT IN
(SELECT DISTINCT ""FilesetID"" FROM FilesetEntry)"));
// Since we are deleting the fileset, we also need to mark the remote volume as deleting so it will be cleaned up later
deletecmd.SetCommandAndParameters(FormatInvariant($@"UPDATE ""RemoteVolume"" SET ""State"" = @NewState WHERE ""ID"" IN (SELECT DISTINCT ""VolumeID"" FROM ""Fileset"" WHERE ""Fileset"".""ID"" IN (SELECT ""ID"" FROM ""{filesetidstable}"")) AND ""State"" IN (@AllowedStates)"))
.SetParameterValue("@NewState", RemoteVolumeState.Deleting.ToString())
.ExpandInClauseParameter("@AllowedStates", [RemoteVolumeState.Uploading.ToString(), RemoteVolumeState.Uploaded.ToString(), RemoteVolumeState.Verified.ToString(), RemoteVolumeState.Temporary.ToString()])
.ExecuteNonQuery();
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""Fileset"" WHERE ""ID"" IN (SELECT ""ID"" FROM ""{filesetidstable}"")"));
// Clean up temp tables for subqueries. We truncate content and then try to delete.
// Drop in try-block, as it fails in nested transactions (SQLite problem)
// SQLite.SQLiteException (0x80004005): database table is locked
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{blocksetidstable}"" "));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{volidstable}"" "));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{metadataFilesetTable}"" "));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{filesetidstable}"" "));
try
{
deletecmd.CommandTimeout = 2;
deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{blocksetidstable}"" "));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{volidstable}"" "));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{metadataFilesetTable}"" "));
deletecmd.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{filesetidstable}"" "));
}
catch { /* Ignore, will be deleted on close anyway. */ }
m_removeremotevolumeCommand.Transaction = transaction;
m_removeremotevolumeCommand.SetParameterValue("@Now", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
m_removeremotevolumeCommand.SetParameterValue("@State", RemoteVolumeState.Deleted.ToString());
foreach (var name in names)
{
m_removeremotevolumeCommand.SetParameterValue("@Name", name);
await m_removeremotevolumeCommand.ExecuteNonQueryAsync();
}
2019-09-07 17:16:34 -04:00
// Validate before commiting changes
var nonAttachedFiles = await deletecmd.ExecuteScalarInt64Async(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FileID"" NOT IN (SELECT ""ID"" FROM ""FileLookup"")");
if (nonAttachedFiles > 0)
throw new ConstraintException($"Detected {nonAttachedFiles} file(s) in FilesetEntry without corresponding FileLookup entry");
2025-03-20 15:59:14 +01:00
await transaction.CommitAsync();
}
2019-08-05 20:14:05 -04:00
public async Task Vacuum()
2013-08-23 22:15:07 +02:00
{
2025-01-14 19:42:03 +01:00
m_hasExecutedVacuum = true;
using var cmd = m_connection.CreateCommand();
await cmd.ExecuteNonQueryAsync("VACUUM");
2013-08-23 22:15:07 +02:00
}
public async Task<long> RegisterRemoteVolume(string name, RemoteVolumeType type, long size, RemoteVolumeState state)
{
var transaction = m_connection.BeginTransaction();
return await RegisterRemoteVolume(name, type, state, size, new TimeSpan(0), transaction);
}
public async Task<long> RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, SqliteTransaction transaction)
{
return await RegisterRemoteVolume(name, type, state, new TimeSpan(0), transaction);
}
public async Task<long> RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, TimeSpan deleteGraceTime, SqliteTransaction transaction)
{
return await RegisterRemoteVolume(name, type, state, -1, deleteGraceTime, transaction);
}
2019-08-05 20:14:05 -04:00
public async Task<long> RegisterRemoteVolume(string name, RemoteVolumeType type, RemoteVolumeState state, long size, TimeSpan deleteGraceTime, SqliteTransaction transaction)
{
m_createremotevolumeCommand.Transaction = transaction;
m_createremotevolumeCommand.SetParameterValue("@OperationId", m_operationid);
m_createremotevolumeCommand.SetParameterValue("@Name", name);
m_createremotevolumeCommand.SetParameterValue("@Type", type.ToString());
m_createremotevolumeCommand.SetParameterValue("@State", state.ToString());
m_createremotevolumeCommand.SetParameterValue("@Size", size);
m_createremotevolumeCommand.SetParameterValue("@VerificationCount", 0);
m_createremotevolumeCommand.SetParameterValue("@DeleteGraceTime", deleteGraceTime.Ticks <= 0 ? 0 : (DateTime.UtcNow + deleteGraceTime).Ticks);
m_createremotevolumeCommand.SetParameterValue("@ArchiveTime", 0);
var r = await m_createremotevolumeCommand.ExecuteScalarInt64Async();
await transaction.CommitAsync();
return r;
}
public async Task<IEnumerable<long>> GetFilesetIDs(DateTime restoretime, long[] versions, bool singleTimeMatch = false)
{
if (restoretime.Kind == DateTimeKind.Unspecified)
throw new Exception("Invalid DateTime given, must be either local or UTC");
(var query, var values) = await GetFilelistWhereClause(restoretime, versions, singleTimeMatch: singleTimeMatch);
var res = new List<long>();
using var cmd = m_connection.CreateCommand();
using (var rd = await cmd.ExecuteReaderAsync($@"SELECT ""ID"" FROM ""Fileset"" {query} ORDER BY ""Timestamp"" DESC", values))
while (await rd.ReadAsync())
res.Add(rd.ConvertValueToInt64(0));
if (res.Count == 0)
2019-08-05 20:14:05 -04:00
{
cmd.Parameters.Clear();
using (var rd = await cmd.ExecuteReaderAsync(@"SELECT ""ID"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC "))
while (await rd.ReadAsync())
res.Add(rd.ConvertValueToInt64(0));
2019-08-05 20:14:05 -04:00
if (res.Count == 0)
throw new Duplicati.Library.Interface.UserInformationException("No backup at the specified date", "NoBackupAtDate");
else
Logging.Log.WriteWarningMessage(LOGTAG, "RestoreTimeNoMatch", null, "Restore time or version did not match any existing backups, selecting newest backup");
}
return res;
2013-03-08 22:24:54 +01:00
}
public async Task<IEnumerable<long>> FindMatchingFilesets(DateTime restoretime, long[] versions)
{
if (restoretime.Kind == DateTimeKind.Unspecified)
throw new Exception("Invalid DateTime given, must be either local or UTC");
var (query, args) = await GetFilelistWhereClause(restoretime, versions, singleTimeMatch: true);
var res = new List<long>();
2019-08-05 20:14:05 -04:00
using (var cmd = m_connection.CreateCommand())
using (var rd = await cmd.ExecuteReaderAsync(@"SELECT ""ID"" FROM ""Fileset"" " + query + @" ORDER BY ""Timestamp"" DESC", args))
while (await rd.ReadAsync())
res.Add(rd.ConvertValueToInt64(0));
return res;
}
2019-08-05 20:14:05 -04:00
2025-05-12 17:09:35 +02:00
public async Task<bool> IsFilesetFullBackup(DateTime filesetTime, SqliteTransaction transaction)
2019-08-05 20:14:05 -04:00
{
2025-05-12 17:09:35 +02:00
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters($@"SELECT ""IsFullBackup"" FROM ""Fileset"" WHERE ""Timestamp"" = @Timestamp");
cmd.SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(filesetTime));
using var rd = await cmd.ExecuteReaderAsync();
if (!await rd.ReadAsync())
return false;
var isFullBackup = rd.GetInt32(0);
return isFullBackup == BackupType.FULL_BACKUP;
2019-08-05 20:14:05 -04:00
}
// TODO: Remove this
2025-03-14 14:34:56 +01:00
public IDbTransaction BeginTransaction()
2013-03-08 22:24:54 +01:00
{
return m_connection.BeginTransactionSafe();
2013-03-08 22:24:54 +01:00
}
protected class TemporaryTransactionWrapper : IDisposable
{
2025-03-14 14:34:56 +01:00
private readonly IDbTransaction m_parent;
private readonly bool m_isTemporary;
2013-03-08 22:24:54 +01:00
2025-04-03 14:20:00 +02:00
public TemporaryTransactionWrapper(IDbConnection connection, IDbTransaction? transaction)
2013-03-08 22:24:54 +01:00
{
if (transaction != null)
{
m_parent = transaction;
m_isTemporary = false;
}
else
{
m_parent = connection.BeginTransactionSafe();
2013-03-08 22:24:54 +01:00
m_isTemporary = true;
}
}
2019-08-05 20:14:05 -04:00
public void Commit()
{
if (m_isTemporary)
m_parent.Commit();
2013-03-08 22:24:54 +01:00
}
2019-08-05 20:14:05 -04:00
public void Dispose()
2013-03-08 22:24:54 +01:00
{
if (m_isTemporary)
m_parent.Dispose();
}
2025-03-14 14:34:56 +01:00
public IDbTransaction Parent { get { return m_parent; } }
}
2019-08-05 20:14:05 -04:00
2025-04-03 14:20:00 +02:00
private IEnumerable<KeyValuePair<string, string>> GetDbOptionList(IDbTransaction? transaction = null)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
using var rd = await cmd.ExecuteReaderAsync(@"SELECT ""Key"", ""Value"" FROM ""Configuration"" ");
while (await rd.ReadAsync())
yield return new KeyValuePair<string, string>(rd.ConvertValueToString(0) ?? "", rd.ConvertValueToString(1) ?? "");
}
2019-08-05 20:14:05 -04:00
public async Task<IDictionary<string, string>> GetDbOptions(SqliteTransaction? transaction)
{
var t = transaction ?? m_connection.BeginTransaction();
var res = await GetDbOptionList(t).ToDictionaryAsync(x => x.Key, x => x.Value);
if (transaction == null)
await t.CommitAsync();
return res;
}
2025-03-07 15:24:56 +01:00
/// <summary>
/// Updates a database option
/// </summary>
/// <param name="key">The key to update</param>
/// <param name="value">The value to set</param>
private async Task UpdateDbOption(string key, bool value)
{
var transaction = m_connection.BeginTransaction();
var opts = await GetDbOptions(transaction);
2025-03-06 12:02:11 +01:00
if (value)
opts[key] = "true";
2025-03-06 12:02:11 +01:00
else
opts.Remove(key);
2019-08-05 20:14:05 -04:00
await SetDbOptions(opts, transaction);
await transaction.CommitAsync();
}
2025-03-07 15:24:56 +01:00
/// <summary>
/// Flag indicating if a repair is in progress
/// </summary>
2025-03-06 12:02:11 +01:00
public bool RepairInProgress
{
get => GetDbOptions(null).Await().ContainsKey("repair-in-progress");
set => UpdateDbOption("repair-in-progress", value).Await();
2025-03-06 12:02:11 +01:00
}
2025-03-07 15:24:56 +01:00
/// <summary>
/// Flag indicating if a repair is in progress
/// </summary>
2025-03-06 12:02:11 +01:00
public bool PartiallyRecreated
{
get => GetDbOptions(null).Await().ContainsKey("partially-recreated");
set => UpdateDbOption("partially-recreated", value).Await();
2025-03-06 12:02:11 +01:00
}
2025-03-07 15:24:56 +01:00
/// <summary>
/// Flag indicating if the database can contain partial uploads
/// </summary>
public bool TerminatedWithActiveUploads
2025-03-06 12:02:11 +01:00
{
get => GetDbOptions(null).Await().ContainsKey("terminated-with-active-uploads");
set => UpdateDbOption("terminated-with-active-uploads", value).Await();
}
2019-08-05 20:14:05 -04:00
2025-03-07 15:24:56 +01:00
/// <summary>
/// Sets the database options
/// </summary>
/// <param name="options">The options to set</param>
/// <param name="transaction">An optional transaction</param>
public async Task SetDbOptions(IDictionary<string, string> options, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
await cmd.ExecuteNonQueryAsync(@"DELETE FROM ""Configuration"" ");
foreach (var kp in options)
{
cmd.SetCommandAndParameters(@"INSERT INTO ""Configuration"" (""Key"", ""Value"") VALUES (@Key, @Value) ");
cmd.SetParameterValue("@Key", kp.Key);
cmd.SetParameterValue("@Value", kp.Value);
await cmd.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
public async Task<long> GetBlocksLargerThan(long fhblocksize)
{
using var cmd = m_connection.CreateCommand(@"SELECT COUNT(*) FROM ""Block"" WHERE ""Size"" > @Size");
cmd.SetParameterValue("@Size", fhblocksize);
return await cmd.ExecuteScalarInt64Async(-1);
}
/// <summary>
/// Verifies the consistency of the database
/// </summary>
/// <param name="blocksize">The block size in bytes</param>
/// <param name="hashsize">The hash size in byts</param>
/// <param name="verifyfilelists">Also verify filelists (can be slow)</param>
/// <param name="transaction">The transaction to run in</param>
public async Task VerifyConsistency(long blocksize, long hashsize, bool verifyfilelists, SqliteTransaction transaction)
=> await VerifyConsistencyInner(blocksize, hashsize, verifyfilelists, false, transaction);
/// <summary>
/// Verifies the consistency of the database prior to repair
/// </summary>
/// <param name="blocksize">The block size in bytes</param>
/// <param name="hashsize">The hash size in byts</param>
/// <param name="verifyfilelists">Also verify filelists (can be slow)</param>
/// <param name="transaction">The transaction to run in</param>
public async Task VerifyConsistencyForRepair(long blocksize, long hashsize, bool verifyfilelists, SqliteTransaction transaction)
=> await VerifyConsistencyInner(blocksize, hashsize, verifyfilelists, true, transaction);
/// <summary>
/// Verifies the consistency of the database
/// </summary>
/// <param name="blocksize">The block size in bytes</param>
/// <param name="hashsize">The hash size in byts</param>
/// <param name="verifyfilelists">Also verify filelists (can be slow)</param>
/// <param name="laxVerifyForRepair">Disable verify for errors that will be fixed by repair</param>
/// <param name="transaction">The transaction to run in</param>
private async Task VerifyConsistencyInner(long blocksize, long hashsize, bool verifyfilelists, bool laxVerifyForRepair, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
// Calculate the lengths for each blockset
var combinedLengths = @"
SELECT
""A"".""ID"" AS ""BlocksetID"",
IFNULL(""B"".""CalcLen"", 0) AS ""CalcLen"",
""A"".""Length""
FROM
""Blockset"" A
LEFT OUTER JOIN
(
SELECT
""BlocksetEntry"".""BlocksetID"",
SUM(""Block"".""Size"") AS ""CalcLen""
FROM
""BlocksetEntry""
LEFT OUTER JOIN
""Block""
ON
""Block"".""ID"" = ""BlocksetEntry"".""BlockID""
GROUP BY ""BlocksetEntry"".""BlocksetID""
) B
ON
""A"".""ID"" = ""B"".""BlocksetID""
";
// For each blockset with wrong lengths, fetch the file path
var reportDetails = @"SELECT ""CalcLen"", ""Length"", ""A"".""BlocksetID"", ""File"".""Path"" FROM (" + combinedLengths + @") A, ""File"" WHERE ""A"".""BlocksetID"" = ""File"".""BlocksetID"" AND ""A"".""CalcLen"" != ""A"".""Length"" ";
2019-08-05 20:14:05 -04:00
using (var rd = await cmd.ExecuteReaderAsync(reportDetails))
if (await rd.ReadAsync())
{
var sb = new StringBuilder();
sb.AppendLine("Found inconsistency in the following files while validating database: ");
var c = 0;
do
{
if (c < 5)
sb.AppendFormat("{0}, actual size {1}, dbsize {2}, blocksetid: {3}{4}", rd.GetValue(3), rd.GetValue(1), rd.GetValue(0), rd.GetValue(2), Environment.NewLine);
c++;
} while (await rd.ReadAsync());
2019-08-05 20:14:05 -04:00
c -= 5;
if (c > 0)
sb.AppendFormat("... and {0} more", c);
2019-08-05 20:14:05 -04:00
sb.Append(". Run repair to fix it.");
throw new DatabaseInconsistencyException(sb.ToString());
}
var real_count = await cmd.ExecuteScalarInt64Async(@"SELECT Count(*) FROM ""BlocklistHash""", 0);
var unique_count = await cmd.ExecuteScalarInt64Async(@"SELECT Count(*) FROM (SELECT DISTINCT ""BlocksetID"", ""Index"" FROM ""BlocklistHash"")", 0);
if (real_count != unique_count)
throw new DatabaseInconsistencyException($"Found {real_count} blocklist hashes, but there should be {unique_count}. Run repair to fix it.");
var itemswithnoblocklisthash = await cmd.ExecuteScalarInt64Async(FormatInvariant($@"SELECT COUNT(*) FROM (SELECT * FROM (SELECT ""N"".""BlocksetID"", ((""N"".""BlockCount"" + {blocksize / hashsize} - 1) / {blocksize / hashsize}) AS ""BlocklistHashCountExpected"", CASE WHEN ""G"".""BlocklistHashCount"" IS NULL THEN 0 ELSE ""G"".""BlocklistHashCount"" END AS ""BlocklistHashCountActual"" FROM (SELECT ""BlocksetID"", COUNT(*) AS ""BlockCount"" FROM ""BlocksetEntry"" GROUP BY ""BlocksetID"") ""N"" LEFT OUTER JOIN (SELECT ""BlocksetID"", COUNT(*) AS ""BlocklistHashCount"" FROM ""BlocklistHash"" GROUP BY ""BlocksetID"") ""G"" ON ""N"".""BlocksetID"" = ""G"".""BlocksetID"" WHERE ""N"".""BlockCount"" > 1) WHERE ""BlocklistHashCountExpected"" != ""BlocklistHashCountActual"")"), 0);
if (itemswithnoblocklisthash != 0)
throw new DatabaseInconsistencyException($"Found {itemswithnoblocklisthash} file(s) with missing blocklist hashes");
if (await cmd.ExecuteScalarInt64Async(@"SELECT COUNT(*) FROM ""Blockset"" WHERE ""Length"" > 0 AND ""ID"" NOT IN (SELECT ""BlocksetId"" FROM ""BlocksetEntry"")") != 0)
throw new DatabaseInconsistencyException("Detected non-empty blocksets with no associated blocks!");
cmd.SetCommandAndParameters(@"SELECT COUNT(*) FROM ""FileLookup"" WHERE ""BlocksetID"" != @FolderBlocksetId AND ""BlocksetID"" != @SymlinkBlocksetId AND NOT ""BlocksetID"" IN (SELECT ""ID"" FROM ""Blockset"")");
cmd.SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID);
cmd.SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID);
if (await cmd.ExecuteScalarInt64Async(0) != 0)
throw new DatabaseInconsistencyException("Detected files associated with non-existing blocksets!");
if (!laxVerifyForRepair)
{
cmd.SetCommandAndParameters(@"SELECT COUNT(*) FROM ""Fileset"" WHERE ""VolumeID"" NOT IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = @Type AND ""State"" != @State)");
cmd.SetParameterValue("@Type", RemoteVolumeType.Files.ToString());
cmd.SetParameterValue("@State", RemoteVolumeState.Deleted.ToString());
var filesetsMissingVolumes = await cmd.ExecuteScalarInt64Async(0);
if (filesetsMissingVolumes != 0)
{
if (filesetsMissingVolumes == 1)
{
cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Timestamp"", ""VolumeID"" FROM ""Fileset"" WHERE ""VolumeID"" NOT IN (SELECT ""ID"" FROM ""RemoteVolume"" WHERE ""Type"" = @Type AND ""State"" != @State)");
cmd.SetParameterValue("@Type", RemoteVolumeType.Files.ToString());
cmd.SetParameterValue("@State", RemoteVolumeState.Deleted.ToString());
using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
throw new DatabaseInconsistencyException($"Detected 1 fileset with missing volume: FilesetId = {reader.ConvertValueToInt64(0)}, Time = ({ParseFromEpochSeconds(reader.ConvertValueToInt64(1))}), unmatched VolumeID {reader.ConvertValueToInt64(2)}");
}
throw new DatabaseInconsistencyException($"Detected {filesetsMissingVolumes} filesets with missing volumes");
}
cmd.SetCommandAndParameters(@"SELECT COUNT(*) FROM ""RemoteVolume"" WHERE ""Type"" = @Type AND ""State"" != @State AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"")");
cmd.SetParameterValue("@Type", RemoteVolumeType.Files.ToString());
cmd.SetParameterValue("@State", RemoteVolumeState.Deleted.ToString());
var volumesMissingFilests = await cmd.ExecuteScalarInt64Async(0);
if (volumesMissingFilests != 0)
{
if (volumesMissingFilests == 1)
{
cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Name"", ""State"" FROM ""RemoteVolume"" WHERE ""Type"" = @Type AND ""State"" != @State AND ""ID"" NOT IN (SELECT ""VolumeID"" FROM ""Fileset"")");
cmd.SetParameterValue("@Type", RemoteVolumeType.Files.ToString());
cmd.SetParameterValue("@State", RemoteVolumeState.Deleted.ToString());
using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
throw new DatabaseInconsistencyException($"Detected 1 volume with missing filesets: VolumeId = {reader.ConvertValueToInt64(0)}, Name = {reader.ConvertValueToString(1)}, State = {reader.ConvertValueToString(2)}");
}
throw new DatabaseInconsistencyException($"Detected {volumesMissingFilests} volumes with missing filesets");
}
}
var nonAttachedFiles = await cmd.ExecuteScalarInt64Async(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FileID"" NOT IN (SELECT ""ID"" FROM ""FileLookup"")");
if (nonAttachedFiles != 0)
{
// Attempt to create a better error message by finding the first 10 fileset ids with the issue
using var filesetIdReader = await cmd.ExecuteReaderAsync(@"SELECT DISTINCT(FilesetID) FROM ""FilesetEntry"" WHERE ""FileID"" NOT IN (SELECT ""ID"" FROM ""FileLookup"") LIMIT 11");
var filesetIds = new HashSet<long>();
var overflow = false;
while (await filesetIdReader.ReadAsync())
2025-03-20 15:59:14 +01:00
{
if (filesetIds.Count >= 10)
2025-03-20 15:59:14 +01:00
{
overflow = true;
break;
2025-03-20 15:59:14 +01:00
}
filesetIds.Add(filesetIdReader.ConvertValueToInt64(0));
}
2025-03-20 15:59:14 +01:00
var pairs = FilesetTimes()
.Select((x, i) => new { FilesetId = x.Key, Version = i, Time = x.Value })
.Where(x => filesetIds.Contains(x.FilesetId))
.Select(x => $"Fileset {x.Version}: {x.Time} (id = {x.FilesetId})");
2025-03-20 15:59:14 +01:00
// Fall back to a generic error message if we can't find the fileset ids
if (!await pairs.AnyAsync())
throw new DatabaseInconsistencyException($"Detected {nonAttachedFiles} file(s) in FilesetEntry without corresponding FileLookup entry");
2025-03-20 15:59:14 +01:00
if (overflow)
pairs = pairs.Append("... and more");
2025-03-20 15:59:14 +01:00
throw new DatabaseInconsistencyException($"Detected {nonAttachedFiles} file(s) in FilesetEntry without corresponding FileLookup entry in the following filesets:{Environment.NewLine}{string.Join(Environment.NewLine, pairs)}");
}
if (verifyfilelists)
{
var anyError = new List<string>();
using (var cmd2 = m_connection.CreateCommand())
{
cmd2.Transaction = transaction;
await foreach (var filesetid in cmd.ExecuteReaderEnumerableAsync(@"SELECT ""ID"" FROM ""Fileset"" ").Select(x => x.ConvertValueToInt64(0, -1)))
2025-03-10 21:02:16 +01:00
{
var expandedCmd = FormatInvariant($@"SELECT COUNT(*) FROM (SELECT DISTINCT ""Path"" FROM ({LocalDatabase.LIST_FILESETS}) UNION SELECT DISTINCT ""Path"" FROM ({LocalDatabase.LIST_FOLDERS_AND_SYMLINKS}))");
cmd2.SetCommandAndParameters(expandedCmd);
cmd2.SetParameterValue("@FilesetId", filesetid);
cmd2.SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID);
cmd2.SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID);
var expandedlist = await cmd.ExecuteScalarInt64Async(0);
//var storedfilelist = cmd2.ExecuteScalarInt64(FormatInvariant(@"SELECT COUNT(*) FROM ""FilesetEntry"", ""FileLookup"" WHERE ""FilesetEntry"".""FilesetID"" = @FilesetId AND ""FileLookup"".""ID"" = ""FilesetEntry"".""FileID"" AND ""FileLookup"".""BlocksetID"" != @FolderBlocksetId AND ""FileLookup"".""BlocksetID"" != @SymlinkBlocksetId"), 0, filesetid, FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID);
cmd2.SetCommandAndParameters(@"SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FilesetEntry"".""FilesetID"" = @FilesetId");
cmd2.SetParameterValue("@FilesetId", filesetid);
var storedlist = await cmd2.ExecuteScalarInt64Async(0);
if (expandedlist != storedlist)
{
var filesetname = filesetid.ToString();
var fileset = await FilesetTimes().Zip(AsyncEnumerable.Range(0, await FilesetTimes().CountAsync()), (a, b) => new Tuple<long, long, DateTime>(b, a.Key, a.Value)).FirstOrDefaultAsync(x => x.Item2 == filesetid);
if (fileset != null)
filesetname = $"version {fileset.Item1}: {fileset.Item3} (database id: {fileset.Item2})";
anyError.Add($"Unexpected difference in fileset {filesetname}, found {expandedlist} entries, but expected {storedlist}");
}
2025-03-10 21:02:16 +01:00
}
}
if (anyError.Any())
{
throw new DatabaseInconsistencyException(string.Join("\n\r", anyError), "FilesetDifferences");
}
}
}
2019-08-05 20:14:05 -04:00
public interface IBlock
{
string Hash { get; }
long Size { get; }
}
internal class Block(string hash, long size) : IBlock
2019-08-05 20:14:05 -04:00
{
public string Hash { get; private set; } = hash;
public long Size { get; private set; } = size;
2019-08-05 20:14:05 -04:00
}
2013-04-27 15:13:14 +02:00
public async IAsyncEnumerable<IBlock> GetBlocks(long volumeid, SqliteTransaction transaction)
2019-08-05 20:14:05 -04:00
{
using var cmd = m_connection.CreateCommand(@"SELECT DISTINCT ""Hash"", ""Size"" FROM ""Block"" WHERE ""VolumeID"" = @VolumeId");
cmd.Transaction = transaction;
cmd.SetParameterValue("@VolumeId", volumeid);
using var rd = await cmd.ExecuteReaderAsync();
while (await rd.ReadAsync())
yield return new Block(rd.ConvertValueToString(0) ?? throw new Exception("Hash is null"), rd.ConvertValueToInt64(1));
}
2013-04-27 15:13:14 +02:00
2025-04-03 14:20:00 +02:00
// TODO: Replace this with an enumerable method
2013-04-27 15:13:14 +02:00
private class BlocklistHashEnumerable : IEnumerable<string>
{
private class BlocklistHashEnumerator : IEnumerator<string>
{
private readonly SqliteDataReader m_reader;
private readonly BlocklistHashEnumerable m_parent;
2025-04-03 14:20:00 +02:00
private string? m_path = null;
2013-04-27 15:13:14 +02:00
private bool m_first = true;
2025-04-03 14:20:00 +02:00
private string? m_current = null;
2013-04-27 15:13:14 +02:00
public BlocklistHashEnumerator(BlocklistHashEnumerable parent, SqliteDataReader reader)
2013-04-27 15:13:14 +02:00
{
m_reader = reader;
m_parent = parent;
}
2025-04-03 14:20:00 +02:00
public string Current { get { return m_current!; } }
2013-04-27 15:13:14 +02:00
public void Dispose()
{
}
2025-03-14 14:34:56 +01:00
object System.Collections.IEnumerator.Current { get { return Current; } }
2013-04-27 15:13:14 +02:00
public bool MoveNext()
{
m_first = false;
if (m_path == null)
{
2025-04-03 14:20:00 +02:00
m_path = m_reader.ConvertValueToString(0);
m_current = m_reader.ConvertValueToString(6);
2013-04-27 15:13:14 +02:00
return true;
}
else
{
if (m_current == null)
return false;
if (!m_reader.ReadAsync().Await())
2013-04-27 15:13:14 +02:00
{
m_current = null;
m_parent.MoreData = false;
return false;
}
2025-04-03 14:20:00 +02:00
var np = m_reader.ConvertValueToString(0);
2013-04-27 15:13:14 +02:00
if (m_path != np)
{
m_current = null;
return false;
}
2025-04-03 14:20:00 +02:00
m_current = m_reader.ConvertValueToString(6);
2013-04-27 15:13:14 +02:00
return true;
}
}
public void Reset()
{
if (!m_first)
throw new Exception("Iterator reset not supported");
m_first = false;
}
}
private readonly SqliteDataReader m_reader;
2013-04-27 15:13:14 +02:00
public BlocklistHashEnumerable(SqliteDataReader reader)
2013-04-27 15:13:14 +02:00
{
m_reader = reader;
2025-03-14 14:34:56 +01:00
MoreData = true;
2013-04-27 15:13:14 +02:00
}
public bool MoreData { get; protected set; }
public IEnumerator<string> GetEnumerator()
{
return new BlocklistHashEnumerator(this, m_reader);
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
2025-03-14 14:34:56 +01:00
return GetEnumerator();
2013-04-27 15:13:14 +02:00
}
}
public const string LIST_FILESETS = @"
SELECT
""L"".""Path"",
""L"".""Lastmodified"",
""L"".""Filelength"",
""L"".""Filehash"",
""L"".""Metahash"",
""L"".""Metalength"",
""L"".""BlocklistHash"",
""L"".""FirstBlockHash"",
""L"".""FirstBlockSize"",
""L"".""FirstMetaBlockHash"",
""L"".""FirstMetaBlockSize"",
""M"".""Hash"" AS ""MetaBlocklistHash""
FROM
(
SELECT
""J"".""Path"",
""J"".""Lastmodified"",
""J"".""Filelength"",
""J"".""Filehash"",
""J"".""Metahash"",
""J"".""Metalength"",
""K"".""Hash"" AS ""BlocklistHash"",
""J"".""FirstBlockHash"",
""J"".""FirstBlockSize"",
""J"".""FirstMetaBlockHash"",
""J"".""FirstMetaBlockSize"",
""J"".""MetablocksetID""
FROM
(
SELECT
""A"".""Path"" AS ""Path"",
""D"".""Lastmodified"" AS ""Lastmodified"",
""B"".""Length"" AS ""Filelength"",
""B"".""FullHash"" AS ""Filehash"",
""E"".""FullHash"" AS ""Metahash"",
2017-08-12 14:09:55 +01:00
""E"".""Length"" AS ""Metalength"",
""A"".""BlocksetID"" AS ""BlocksetID"",
""F"".""Hash"" AS ""FirstBlockHash"",
""F"".""Size"" AS ""FirstBlockSize"",
""H"".""Hash"" AS ""FirstMetaBlockHash"",
""H"".""Size"" AS ""FirstMetaBlockSize"",
""C"".""BlocksetID"" AS ""MetablocksetID""
FROM
""File"" A
2017-08-12 14:09:55 +01:00
LEFT JOIN ""Blockset"" B
ON ""A"".""BlocksetID"" = ""B"".""ID""
LEFT JOIN ""Metadataset"" C
2017-08-12 14:09:55 +01:00
ON ""A"".""MetadataID"" = ""C"".""ID""
LEFT JOIN ""FilesetEntry"" D
ON ""A"".""ID"" = ""D"".""FileID""
LEFT JOIN ""Blockset"" E
ON ""E"".""ID"" = ""C"".""BlocksetID""
LEFT JOIN ""BlocksetEntry"" G
ON ""B"".""ID"" = ""G"".""BlocksetID""
LEFT JOIN ""Block"" F
ON ""G"".""BlockID"" = ""F"".""ID""
2017-08-12 14:09:55 +01:00
LEFT JOIN ""BlocksetEntry"" I
ON ""E"".""ID"" = ""I"".""BlocksetID""
LEFT JOIN ""Block"" H
2017-08-12 14:09:55 +01:00
ON ""I"".""BlockID"" = ""H"".""ID""
WHERE
2017-08-12 14:09:55 +01:00
""A"".""BlocksetId"" >= 0 AND
""D"".""FilesetID"" = @FilesetId AND
(""I"".""Index"" = 0 OR ""I"".""Index"" IS NULL) AND
2017-08-12 14:09:55 +01:00
(""G"".""Index"" = 0 OR ""G"".""Index"" IS NULL)
) J
LEFT OUTER JOIN
""BlocklistHash"" K
ON
""K"".""BlocksetID"" = ""J"".""BlocksetID""
ORDER BY ""J"".""Path"", ""K"".""Index""
) L
LEFT OUTER JOIN
""BlocklistHash"" M
ON
""M"".""BlocksetID"" = ""L"".""MetablocksetID""
";
public const string LIST_FOLDERS_AND_SYMLINKS = @"
SELECT
""G"".""BlocksetID"",
""G"".""ID"",
""G"".""Path"",
""G"".""Length"",
""G"".""FullHash"",
""G"".""Lastmodified"",
""G"".""FirstMetaBlockHash"",
""H"".""Hash"" AS ""MetablocklistHash""
FROM
(
SELECT
""B"".""BlocksetID"",
""B"".""ID"",
""B"".""Path"",
""D"".""Length"",
""D"".""FullHash"",
""A"".""Lastmodified"",
""F"".""Hash"" AS ""FirstMetaBlockHash"",
""C"".""BlocksetID"" AS ""MetaBlocksetID""
FROM
""FilesetEntry"" A,
""File"" B,
""Metadataset"" C,
""Blockset"" D,
""BlocksetEntry"" E,
""Block"" F
WHERE
""A"".""FileID"" = ""B"".""ID""
AND ""B"".""MetadataID"" = ""C"".""ID""
AND ""C"".""BlocksetID"" = ""D"".""ID""
AND ""E"".""BlocksetID"" = ""C"".""BlocksetID""
AND ""E"".""BlockID"" = ""F"".""ID""
AND ""E"".""Index"" = 0
AND (""B"".""BlocksetID"" = @FolderBlocksetId OR ""B"".""BlocksetID"" = @SymlinkBlocksetId)
AND ""A"".""FilesetID"" = @FilesetId
) G
LEFT OUTER JOIN
""BlocklistHash"" H
ON
""H"".""BlocksetID"" = ""G"".""MetaBlocksetID""
ORDER BY
""G"".""Path"", ""H"".""Index""
";
public async Task WriteFileset(Volumes.FilesetVolumeWriter filesetvolume, long filesetId, SqliteTransaction transaction)
2013-04-27 15:13:14 +02:00
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters(LIST_FOLDERS_AND_SYMLINKS);
cmd.SetParameterValue("@FilesetId", filesetId);
cmd.SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID);
cmd.SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID);
2019-08-05 20:14:05 -04:00
string? lastpath = null;
using (var rd = await cmd.ExecuteReaderAsync())
while (await rd.ReadAsync())
{
var blocksetID = rd.ConvertValueToInt64(0, -1);
var path = rd.ConvertValueToString(2);
var metalength = rd.ConvertValueToInt64(3, -1);
var metahash = rd.ConvertValueToString(4);
var metablockhash = rd.ConvertValueToString(6);
var metablocklisthash = rd.ConvertValueToString(7);
if (path == lastpath)
Logging.Log.WriteWarningMessage(LOGTAG, "DuplicatePathFound", null, "Duplicate path detected: {0}", path);
lastpath = path;
if (blocksetID == FOLDER_BLOCKSET_ID)
filesetvolume.AddDirectory(path, metahash, metalength, metablockhash, string.IsNullOrWhiteSpace(metablocklisthash) ? null : new string[] { metablocklisthash });
else if (blocksetID == SYMLINK_BLOCKSET_ID)
filesetvolume.AddSymlink(path, metahash, metalength, metablockhash, string.IsNullOrWhiteSpace(metablocklisthash) ? null : new string[] { metablocklisthash });
}
2019-08-05 20:14:05 -04:00
// TODO: Perhaps run the above query after recreate and compare count(*) with count(*) from filesetentry where id = x
2013-04-27 15:13:14 +02:00
cmd.SetCommandAndParameters(LIST_FILESETS);
cmd.SetParameterValue("@FilesetId", filesetId);
2013-04-27 15:13:14 +02:00
using (var rd = await cmd.ExecuteReaderAsync())
if (await rd.ReadAsync())
{
var more = false;
do
2013-04-27 15:13:14 +02:00
{
var path = rd.ConvertValueToString(0);
var filehash = rd.ConvertValueToString(3);
var size = rd.ConvertValueToInt64(2);
var lastmodified = new DateTime(rd.ConvertValueToInt64(1, 0), DateTimeKind.Utc);
var metahash = rd.ConvertValueToString(4);
var metasize = rd.ConvertValueToInt64(5, -1);
var p = rd.GetValue(6);
var blrd = (p == null || p == DBNull.Value) ? null : new BlocklistHashEnumerable(rd);
var blockhash = rd.ConvertValueToString(7);
var blocksize = rd.ConvertValueToInt64(8, -1);
var metablockhash = rd.ConvertValueToString(9);
//var metablocksize = rd.ConvertValueToInt64(10, -1);
var metablocklisthash = rd.ConvertValueToString(11);
if (blockhash == filehash)
blockhash = null;
if (metablockhash == metahash)
metablockhash = null;
filesetvolume.AddFile(path, filehash, size, lastmodified, metahash, metasize, metablockhash, blockhash, blocksize, blrd, string.IsNullOrWhiteSpace(metablocklisthash) ? null : new string[] { metablocklisthash });
if (blrd == null)
more = await rd.ReadAsync();
else
more = blrd.MoreData;
} while (more);
}
2013-04-27 15:13:14 +02:00
}
2019-08-05 20:14:05 -04:00
public async Task LinkFilesetToVolume(long filesetid, long volumeid, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters(@"UPDATE ""Fileset"" SET ""VolumeID"" = @VolumeId WHERE ""ID"" = @FilesetId");
cmd.SetParameterValue("@VolumeId", volumeid);
cmd.SetParameterValue("@FilesetId", filesetid);
var c = await cmd.ExecuteNonQueryAsync();
if (c != 1)
throw new Exception($"Failed to link filesetid {filesetid} to volumeid {volumeid}");
}
public async Task PushTimestampChangesToPreviousVersion(long filesetId, SqliteTransaction transaction)
{
var query = @"
UPDATE FilesetEntry AS oldVersion
SET Lastmodified = tempVersion.Lastmodified
FROM FilesetEntry AS tempVersion
WHERE oldVersion.FileID = tempVersion.FileID
AND tempVersion.FilesetID = @FilesetId
AND oldVersion.FilesetID = (SELECT ID FROM Fileset WHERE ID != @FilesetId ORDER BY Timestamp DESC LIMIT 1)";
using var cmd = m_connection.CreateCommand(query);
cmd.Transaction = transaction;
cmd.SetParameterValue("@FilesetId", filesetId);
await cmd.ExecuteNonQueryAsync();
}
2013-05-11 12:03:15 +02:00
/// <summary>
2019-09-07 17:16:34 -04:00
/// Keeps a list of filenames in a temporary table with a single column Path
/// </summary>
2013-05-11 12:03:15 +02:00
public class FilteredFilenameTable : IDisposable
{
public string Tablename { get; private set; }
private readonly SqliteConnection m_connection;
public FilteredFilenameTable(SqliteConnection connection, IFilter filter, SqliteTransaction transaction)
2013-05-11 12:03:15 +02:00
{
m_connection = connection;
Tablename = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray());
2025-03-14 14:34:56 +01:00
var type = FilterType.Regexp;
2019-09-29 20:16:28 -07:00
if (filter is FilterExpression expression)
type = expression.Type;
// Bugfix: SQLite does not handle case-insensitive LIKE with non-ascii characters
2025-04-03 14:20:00 +02:00
if (type != FilterType.Regexp && !Library.Utility.Utility.IsFSCaseSensitive && filter.ToString()!.Any(x => x > 127))
2025-03-14 14:34:56 +01:00
type = FilterType.Regexp;
2019-08-05 20:14:05 -04:00
if (filter.Empty)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.ExecuteNonQueryAsync(FormatInvariant($@"CREATE TEMPORARY TABLE ""{Tablename}"" AS SELECT DISTINCT ""Path"" FROM ""File"" ")).Await();
return;
}
2025-03-14 14:34:56 +01:00
if (type == FilterType.Regexp || type == FilterType.Group)
2013-05-11 12:03:15 +02:00
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
// TODO: Optimize this to not rely on the "File" view, and not instantiate the paths in full
cmd.ExecuteNonQueryAsync(FormatInvariant($@"CREATE TEMPORARY TABLE ""{Tablename}"" (""Path"" TEXT NOT NULL)")).Await();
cmd.SetCommandAndParameters(FormatInvariant($@"INSERT INTO ""{Tablename}"" (""Path"") VALUES (@Path)"));
using (var c2 = m_connection.CreateCommand())
using (var rd = c2.ExecuteReaderAsync(@"SELECT DISTINCT ""Path"" FROM ""File"" ").Await())
while (rd.ReadAsync().Await())
2013-05-11 12:03:15 +02:00
{
var p = rd.ConvertValueToString(0);
if (FilterExpression.Matches(filter, p))
{
cmd.SetParameterValue("@Path", p);
cmd.ExecuteNonQueryAsync().Await();
}
2013-05-11 12:03:15 +02:00
}
transaction.CommitAsync().Await();
2013-05-11 12:03:15 +02:00
}
else
{
var sb = new StringBuilder();
2025-04-03 14:20:00 +02:00
var args = new Dictionary<string, object?>();
2025-03-14 14:34:56 +01:00
foreach (var f in ((FilterExpression)filter).GetSimpleList())
2013-05-11 12:03:15 +02:00
{
if (sb.Length != 0)
sb.Append(" OR ");
var argName = $"@Arg{args.Count}";
2020-09-01 21:45:27 -07:00
if (type == FilterType.Wildcard)
2013-05-11 12:03:15 +02:00
{
sb.Append(FormatInvariant(@$"""Path"" LIKE {argName}"));
args.Add(argName, f.Replace('*', '%').Replace('?', '_'));
2013-05-11 12:03:15 +02:00
}
else
{
sb.Append(FormatInvariant(@$"""Path"" = {argName}"));
args.Add(argName, f);
2013-05-11 12:03:15 +02:00
}
}
2019-08-05 20:14:05 -04:00
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.ExecuteNonQueryAsync(FormatInvariant($@"CREATE TEMPORARY TABLE ""{Tablename}"" (""Path"" TEXT NOT NULL)")).Await();
cmd.ExecuteNonQueryAsync(FormatInvariant($@"INSERT INTO ""{Tablename}"" SELECT DISTINCT ""Path"" FROM ""File"" WHERE {sb}"), args).Await();
transaction.CommitAsync().Await();
2013-05-11 12:03:15 +02:00
}
}
2019-08-05 20:14:05 -04:00
2013-05-11 12:03:15 +02:00
public void Dispose()
{
if (Tablename != null)
2019-08-05 20:14:05 -04:00
try
{
using var cmd = m_connection.CreateCommand();
cmd.ExecuteNonQueryAsync(FormatInvariant(@$"DROP TABLE IF EXISTS ""{Tablename}"" ")).Await();
2013-05-11 12:03:15 +02:00
}
catch { }
2025-04-03 14:20:00 +02:00
finally { Tablename = null!; }
2019-08-05 20:14:05 -04:00
}
2013-05-11 12:03:15 +02:00
}
2019-08-05 20:14:05 -04:00
public async Task RenameRemoteFile(string oldname, string newname, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
//Rename the old entry, to preserve ID links
cmd.SetCommandAndParameters(@"UPDATE ""Remotevolume"" SET ""Name"" = @Newname WHERE ""Name"" = @Oldname");
cmd.SetParameterValue("@Newname", newname);
cmd.SetParameterValue("@Oldname", oldname);
var c = await cmd.ExecuteNonQueryAsync();
if (c != 1)
throw new Exception($"Unexpected result from renaming \"{oldname}\" to \"{newname}\", expected {1} got {c}");
// Grab the type of entry
cmd.SetCommandAndParameters(@"SELECT ""Type"" FROM ""Remotevolume"" WHERE ""Name"" = @Name");
cmd.SetParameterValue("@Name", newname);
var type = (RemoteVolumeType)Enum.Parse(
typeof(RemoteVolumeType), (await cmd.ExecuteScalarAsync())?.ToString() ?? "",
true);
//Create a fake new entry with the old name and mark as deleting
// as this ensures we will remove it, if it shows up in some later listing
await RegisterRemoteVolume(oldname, type, RemoteVolumeState.Deleting, transaction);
await transaction.CommitAsync();
}
2019-08-05 20:14:05 -04:00
/// <summary>
/// Creates a timestamped backup operation to correctly associate the fileset with the time it was created.
/// </summary>
/// <param name="volumeid">The ID of the fileset volume to update</param>
/// <param name="timestamp">The timestamp of the operation to create</param>
/// <param name="transaction">An optional external transaction</param>
public virtual async Task<long> CreateFileset(long volumeid, DateTime timestamp, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters(@"INSERT INTO ""Fileset"" (""OperationID"", ""Timestamp"", ""VolumeID"", ""IsFullBackup"") VALUES (@OperationId, @Timestamp, @VolumeId, @IsFullBackup); SELECT last_insert_rowid();");
cmd.SetParameterValue("@OperationId", m_operationid);
cmd.SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(timestamp));
cmd.SetParameterValue("@VolumeId", volumeid);
cmd.SetParameterValue("@IsFullBackup", BackupType.PARTIAL_BACKUP);
var id = await cmd.ExecuteScalarInt64Async(-1);
await transaction.CommitAsync();
return id;
}
/// <summary>
/// Adds a link between an index volume and a block volume.
/// </summary>
/// <param name="indexVolumeID">The ID of the index volume.</param>
/// <param name="blockVolumeID">The ID of the block volume.</param>
/// <param name="transaction">An optional transaction.</param>
2025-05-12 17:09:35 +02:00
public async Task AddIndexBlockLink(long indexVolumeID, long blockVolumeID, SqliteTransaction transaction)
{
if (indexVolumeID <= 0)
throw new ArgumentOutOfRangeException(nameof(indexVolumeID), "Index volume ID must be greater than 0.");
if (blockVolumeID <= 0)
throw new ArgumentOutOfRangeException(nameof(blockVolumeID), "Block volume ID must be greater than 0.");
2025-05-12 17:09:35 +02:00
m_insertIndexBlockLink.Transaction = transaction;
m_insertIndexBlockLink.SetParameterValue("@IndexVolumeId", indexVolumeID);
m_insertIndexBlockLink.SetParameterValue("@BlockVolumeId", blockVolumeID);
await m_insertIndexBlockLink.ExecuteNonQueryAsync();
}
2024-09-09 15:27:37 +02:00
/// <summary>
/// Returns all unique blocklists for a given volume
/// </summary>
/// <param name="volumeid">The volume ID to get blocklists for</param>
/// <param name="blocksize">The blocksize</param>
/// <param name="hashsize">The size of the hash</param>
/// <param name="transaction">An optional external transaction</param>
/// <returns>An enumerable of tuples containing the blocklist hash, the blocklist data and the length of the data</returns>
public async IAsyncEnumerable<Tuple<string, byte[], int>> GetBlocklists(long volumeid, long blocksize, int hashsize, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
// Group subquery by hash to ensure that each blocklist hash appears only once in the result
var sql = FormatInvariant($@"SELECT ""A"".""Hash"", ""C"".""Hash"" FROM
(SELECT ""BlocklistHash"".""BlocksetID"", ""Block"".""Hash"", ""BlocklistHash"".""Index"" FROM ""BlocklistHash"",""Block"" WHERE ""BlocklistHash"".""Hash"" = ""Block"".""Hash"" AND ""Block"".""VolumeID"" = @VolumeId GROUP BY ""Block"".""Hash"", ""Block"".""Size"") A,
""BlocksetEntry"" B, ""Block"" C WHERE ""B"".""BlocksetID"" = ""A"".""BlocksetID"" AND
""B"".""Index"" >= (""A"".""Index"" * {blocksize / hashsize}) AND ""B"".""Index"" < ((""A"".""Index"" + 1) * {blocksize / hashsize}) AND ""C"".""ID"" = ""B"".""BlockID""
ORDER BY ""A"".""BlocksetID"", ""B"".""Index""");
string? curHash = null;
var count = 0;
var buffer = new byte[blocksize];
cmd.SetCommandAndParameters(sql);
cmd.SetParameterValue("@VolumeId", volumeid);
using (var rd = await cmd.ExecuteReaderAsync())
while (await rd.ReadAsync())
{
var blockhash = rd.ConvertValueToString(0);
if ((blockhash != curHash && curHash != null) || count + hashsize > buffer.Length)
{
yield return new Tuple<string, byte[], int>(curHash!, buffer, count);
buffer = new byte[blocksize];
count = 0;
}
var hash = Convert.FromBase64String(rd.ConvertValueToString(1) ?? throw new Exception("Hash is null"));
Array.Copy(hash, 0, buffer, count, hashsize);
curHash = blockhash;
count += hashsize;
}
if (curHash != null)
yield return new Tuple<string, byte[], int>(curHash, buffer, count);
}
2015-02-15 23:26:52 +01:00
2019-09-01 13:12:03 -04:00
/// <summary>
/// Update fileset with full backup state
2019-09-01 13:12:03 -04:00
/// </summary>
/// <param name="fileSetId">Existing file set to update</param>
/// <param name="isFullBackup">Full backup state</param>
2019-09-01 13:12:03 -04:00
/// <param name="transaction">An optional external transaction</param>
public async Task UpdateFullBackupStateInFileset(long fileSetId, bool isFullBackup, SqliteTransaction transaction)
2019-09-01 13:12:03 -04:00
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters(@"UPDATE ""Fileset"" SET ""IsFullBackup"" = @IsFullBackup WHERE ""ID"" = @FilesetId;");
cmd.SetParameterValue("@FilesetId", fileSetId);
cmd.SetParameterValue("@IsFullBackup", isFullBackup ? BackupType.FULL_BACKUP : BackupType.PARTIAL_BACKUP);
await cmd.ExecuteNonQueryAsync();
await transaction.CommitAsync();
2019-09-01 13:12:03 -04:00
}
/// <summary>
/// Removes all entries in the fileset entry table for a given fileset ID
/// </summary>
/// <param name="filesetId">The fileset ID to clear</param>
/// <param name="transaction">The transaction to use</param>
public async Task ClearFilesetEntries(long filesetId, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters(@"DELETE FROM ""FilesetEntry"" WHERE ""FilesetID"" = @FilesetId");
cmd.SetParameterValue("@FilesetId", filesetId);
await cmd.ExecuteNonQueryAsync();
}
/// <summary>
/// Gets the last previous fileset that was incomplete
/// </summary>
/// <param name="transaction">The transaction to use</param>
/// <returns>The last incomplete fileset or default</returns>
public async Task<RemoteVolumeEntry> GetLastIncompleteFilesetVolume(SqliteTransaction transaction)
{
var candidates = GetIncompleteFilesets(transaction).OrderBy(x => x.Value);
if (await candidates.AnyAsync())
return await GetRemoteVolumeFromFilesetID((await candidates.LastAsync()).Key, transaction);
return default;
}
/// <summary>
/// Gets a list of incomplete filesets
/// </summary>
/// <param name="transaction">An optional transaction</param>
/// <returns>A list of fileset IDs and timestamps</returns>
public async IAsyncEnumerable<KeyValuePair<long, DateTime>> GetIncompleteFilesets(SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
using var rd = await cmd.ExecuteReaderAsync(FormatInvariant(@$"SELECT DISTINCT ""Fileset"".""ID"", ""Fileset"".""Timestamp"" FROM ""Fileset"", ""RemoteVolume"" WHERE ""RemoteVolume"".""ID"" = ""Fileset"".""VolumeID"" AND ""Fileset"".""ID"" IN (SELECT ""FilesetID"" FROM ""FilesetEntry"") AND (""RemoteVolume"".""State"" = '{RemoteVolumeState.Uploading}' OR ""RemoteVolume"".""State"" = '{RemoteVolumeState.Temporary}')"));
while (await rd.ReadAsync())
{
yield return new KeyValuePair<long, DateTime>(
rd.ConvertValueToInt64(0),
ParseFromEpochSeconds(rd.ConvertValueToInt64(1)).ToLocalTime()
);
}
}
2019-09-01 13:12:03 -04:00
/// <summary>
/// Gets the remote volume entry from the fileset ID
/// </summary>
/// <param name="filesetID">The fileset ID</param>
/// <param name="transaction">An optional transaction</param>
/// <returns>The remote volume entry or default</returns>
public async Task<RemoteVolumeEntry> GetRemoteVolumeFromFilesetID(long filesetID, SqliteTransaction transaction)
{
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
cmd.SetCommandAndParameters(@"SELECT ""RemoteVolume"".""ID"", ""Name"", ""Type"", ""Size"", ""Hash"", ""State"", ""DeleteGraceTime"", ""ArchiveTime"" FROM ""RemoteVolume"", ""Fileset"" WHERE ""Fileset"".""VolumeID"" = ""RemoteVolume"".""ID"" AND ""Fileset"".""ID"" = @FilesetId");
cmd.SetParameterValue("@FilesetId", filesetID);
using var rd = await cmd.ExecuteReaderAsync();
if (await rd.ReadAsync())
return new RemoteVolumeEntry(
rd.ConvertValueToInt64(0, -1),
rd.ConvertValueToString(1),
rd.ConvertValueToString(4),
rd.ConvertValueToInt64(3, -1),
(RemoteVolumeType)Enum.Parse(typeof(RemoteVolumeType), rd.ConvertValueToString(2) ?? ""),
(RemoteVolumeState)Enum.Parse(typeof(RemoteVolumeState), rd.ConvertValueToString(5) ?? ""),
ParseFromEpochSeconds(rd.ConvertValueToInt64(6)).ToLocalTime(),
ParseFromEpochSeconds(rd.ConvertValueToInt64(7)).ToLocalTime()
);
else
return default(RemoteVolumeEntry);
2019-09-01 13:12:03 -04:00
}
public async Task PurgeLogData(DateTime threshold)
2015-02-15 23:26:52 +01:00
{
using var transaction = m_connection.BeginTransaction();
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
var t = Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(threshold);
cmd.SetCommandAndParameters(@"DELETE FROM ""LogData"" WHERE ""Timestamp"" < @Timestamp");
cmd.SetParameterValue("@Timestamp", t);
await cmd.ExecuteNonQueryAsync();
cmd.SetCommandAndParameters(@"DELETE FROM ""RemoteOperation"" WHERE ""Timestamp"" < @Timestamp");
cmd.SetParameterValue("@Timestamp", t);
await cmd.ExecuteNonQueryAsync();
await transaction.CommitAsync();
2015-02-15 23:26:52 +01:00
}
2019-08-05 20:14:05 -04:00
public async Task PurgeDeletedVolumes(DateTime threshold)
2019-09-07 17:16:34 -04:00
{
using var transaction = m_connection.BeginTransaction();
using var cmd = m_connection.CreateCommand();
cmd.Transaction = transaction;
m_removedeletedremotevolumeCommand.SetParameterValue("@Now", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(threshold));
await m_removedeletedremotevolumeCommand.ExecuteNonQueryAsync();
await transaction.CommitAsync();
2019-09-07 17:16:34 -04:00
}
public virtual void Dispose()
{
if (IsDisposed)
return;
2025-05-12 17:09:35 +02:00
DisposeAllFields<SqliteCommand>(this, false);
if (ShouldCloseConnection && m_connection != null)
{
2025-03-14 14:34:56 +01:00
if (m_connection.State == ConnectionState.Open && !m_hasExecutedVacuum)
{
using (var transaction = m_connection.BeginTransaction())
using (var command = m_connection.CreateCommand())
{
command.Transaction = transaction;
2025-01-28 08:55:28 +01:00
// SQLite recommends that PRAGMA optimize is run just before closing each database connection.
2025-05-12 17:09:35 +02:00
command.ExecuteNonQueryAsync("PRAGMA optimize").Await();
2025-01-28 08:55:28 +01:00
try
{
transaction.CommitAsync().Await();
}
2025-04-23 16:40:36 +02:00
catch (Exception ex)
2025-01-28 08:55:28 +01:00
{
Logging.Log.WriteVerboseMessage(LOGTAG, "FailedToCommitTransaction", ex, "Failed to commit transaction after pragma optimize, usually caused by the a no-op transaction");
}
}
m_connection.CloseAsync().Await();
}
m_connection.Dispose();
}
IsDisposed = true;
}
/// <summary>
/// Disposes all fields of a certain type, in the instance and its bases
/// </summary>
/// <typeparam name="T">The type of fields to find</typeparam>
/// <param name="item">The item to dispose</param>
/// <param name="throwExceptions"><c>True</c> if an aggregate exception should be thrown, or <c>false</c> if exceptions are silently captured</param>
public static void DisposeAllFields<T>(object item, bool throwExceptions)
where T : IDisposable
{
var typechain = new List<Type>();
var cur = item.GetType();
var exceptions = new List<Exception>();
while (cur != null && cur != typeof(object))
{
typechain.Add(cur);
cur = cur.BaseType;
}
var fields =
typechain.SelectMany(x =>
x.GetFields(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.FlattenHierarchy)
).Distinct().Where(x => x.FieldType.IsAssignableFrom(typeof(T)));
foreach (var p in fields)
try
{
var val = p.GetValue(item);
if (val != null)
((T)val).Dispose();
}
catch (Exception ex)
{
if (throwExceptions)
exceptions.Add(ex);
}
if (exceptions.Count > 0)
throw new AggregateException(exceptions);
}
public async Task WriteResults(IBasicResults result)
{
if (IsDisposed)
return;
2025-03-20 09:51:49 +01:00
if (m_connection != null && result != null)
{
2025-03-20 09:51:49 +01:00
if (result is BasicResults basicResults)
{
basicResults.FlushLog(this);
if (basicResults.EndTime.Ticks == 0)
basicResults.EndTime = DateTime.UtcNow;
}
2016-12-01 23:59:54 +01:00
2018-11-14 08:47:01 -02:00
var serializer = new JsonFormatSerializer();
await LogMessage("Result",
2025-03-20 09:51:49 +01:00
serializer.SerializeResults(result),
null,
null
);
}
}
/// <summary>
/// The current index into the path prefix buffer
/// </summary>
private int m_pathPrefixIndex = 0;
/// <summary>
/// The path prefix lookup list
/// </summary>
private readonly KeyValuePair<string, long>[] m_pathPrefixLookup = new KeyValuePair<string, long>[5];
/// <summary>
/// Gets the path prefix ID, optionally creating it in the process.
/// </summary>
/// <returns>The path prefix ID.</returns>
/// <param name="prefix">The path to get the prefix for.</param>
/// <param name="transaction">The transaction to use for insertion, or null for no transaction</param>
public async Task<long> GetOrCreatePathPrefix(string prefix, SqliteTransaction transaction)
{
// Ring-buffer style lookup
for (var i = 0; i < m_pathPrefixLookup.Length; i++)
{
var ix = (i + m_pathPrefixIndex) % m_pathPrefixLookup.Length;
if (string.Equals(m_pathPrefixLookup[ix].Key, prefix, StringComparison.Ordinal))
return m_pathPrefixLookup[ix].Value;
}
m_findpathprefixCommand.Transaction = transaction;
m_findpathprefixCommand.SetParameterValue("@Prefix", prefix);
var id = await m_findpathprefixCommand.ExecuteScalarInt64Async();
if (id < 0)
{
m_insertpathprefixCommand.SetParameterValue("@Prefix", prefix);
m_insertpathprefixCommand.Transaction = transaction;
id = await m_insertpathprefixCommand.ExecuteScalarInt64Async();
}
m_pathPrefixIndex = (m_pathPrefixIndex + 1) % m_pathPrefixLookup.Length;
m_pathPrefixLookup[m_pathPrefixIndex] = new KeyValuePair<string, long>(prefix, id);
return id;
}
/// <summary>
/// The path separators on this system
/// </summary>
private static readonly char[] _pathseparators = [
2025-03-14 14:34:56 +01:00
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar,
];
/// <summary>
/// Helper method that splits a path on the last path separator
/// </summary>
/// <returns>The prefix and name.</returns>
/// <param name="path">The path to split.</param>
public static KeyValuePair<string, string> SplitIntoPrefixAndName(string path)
{
2019-02-04 17:32:56 +01:00
if (string.IsNullOrEmpty(path))
throw new ArgumentException($"Invalid path: {path}", nameof(path));
int nLast = path.TrimEnd(_pathseparators).LastIndexOfAny(_pathseparators);
if (nLast >= 0)
return new KeyValuePair<string, string>(path.Substring(0, nLast + 1), path.Substring(nLast + 1));
2019-02-04 17:32:56 +01:00
return new KeyValuePair<string, string>(string.Empty, path);
2019-08-05 20:14:05 -04:00
}
2019-09-15 13:45:43 -07:00
}
2019-08-18 14:41:05 -04:00
2019-09-15 13:45:43 -07:00
/// <summary>
/// Defines the backups types
/// </summary>
public static class BackupType
{
public const int PARTIAL_BACKUP = 0;
public const int FULL_BACKUP = 1;
}
}