// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Dapper; using EpicGames.Core; using Microsoft.Data.Sqlite; namespace EpicGames.Horde.Storage { /// /// Database of file metadata, storing timestamps for trees of files and hashes for ranges within them. Implemented as a SQLite database. /// public sealed class FileMetadataDb : IDisposable { /// /// Id value for the root directory /// public const int RootDirectoryId = 0; class IoHashTypeMapper : SqlMapper.TypeHandler { public override IoHash Parse(object value) { return IoHash.Parse((string)value); } public override void SetValue(IDbDataParameter parameter, IoHash value) { parameter.DbType = DbType.AnsiStringFixedLength; parameter.Value = value.ToString(); } } static readonly IoHashTypeMapper s_hashTypeMapper = new IoHashTypeMapper(); readonly SqliteConnection _connection; FileMetadataDb(SqliteConnection connection) { _connection = connection; } /// public void Dispose() { _connection.Dispose(); } /// /// Creates a new in-memory database for file metadata /// public static async Task CreateInMemoryAsync(CancellationToken cancellationToken = default) { return await CreateAsync("Data Source=:memory:", cancellationToken); } /// /// Creates a new database for metadata backed by a file on disk /// public static async Task CreateFromFileAsync(FileReference file, CancellationToken cancellationToken = default) { return await CreateAsync($"Data Source={file}", cancellationToken); } static async Task CreateAsync(string connectionString, CancellationToken cancellationToken) { SqlMapper.AddTypeHandler(s_hashTypeMapper); SqliteConnection? connection = new SqliteConnection(connectionString); try { await connection.OpenAsync(cancellationToken); int version = await connection.ExecuteScalarAsync("PRAGMA user_version;"); if (version == 0) { // Configure the directories table await connection.ExecuteAsync( "CREATE TABLE Directories (" + " id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + " parentDirectoryId INTEGER," + " name TEXT NOT NULL," + " FOREIGN KEY (parentDirectoryId) REFERENCES Directories(id));", cancellationToken); await connection.ExecuteAsync( "CREATE INDEX DirectoryParents ON Directories(parentDirectoryId);", cancellationToken); await connection.ExecuteAsync( "INSERT INTO Directories VALUES (0, NULL, \"\");", cancellationToken); // Configure the files table await connection.ExecuteAsync( "CREATE TABLE Files (" + " id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + " directoryId INTEGER NOT NULL," + " name TEXT NOT NULL," + " time INTEGER NOT NULL," + " length INTEGER NOT NULL," + " FOREIGN KEY(directoryId) REFERENCES Directories(id));", cancellationToken); await connection.ExecuteAsync( "CREATE INDEX FileParents ON Files(directoryId);", cancellationToken); // Configure the chunks table await connection.ExecuteAsync( "CREATE TABLE Chunks (" + " id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + " fileId INTEGER NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " hash VARCHAR NOT NULL," + " FOREIGN KEY(fileId) REFERENCES Files(id));", cancellationToken); await connection.ExecuteAsync( "CREATE INDEX ChunkParents ON Chunks(fileId);", cancellationToken); await connection.ExecuteAsync( "CREATE INDEX ChunkHashes ON Chunks(hash);", cancellationToken); // Set the DB version number to 1 await connection.ExecuteAsync( "PRAGMA user_version = 1;", cancellationToken); version = 1; } return new FileMetadataDb(connection); } catch { await connection.DisposeAsync(); throw; } } #region Files /// /// Adds a single file to the database /// public async Task AddFileAsync(int directoryId, string name, DateTime time, long length) { FileRow row = new FileRow(directoryId, name, time, length); return await _connection.ExecuteScalarAsync("INSERT INTO Files (DirectoryId, Name, Time, Length) VALUES (@DirectoryId, @Name, @Time, @Length); SELECT last_insert_rowid();", row); } /// /// Adds a set of files to the database /// public async Task AddFilesAsync(IEnumerable files) { await _connection.ExecuteAsync("INSERT INTO Files (DirectoryId, Name, Time, Length) VALUES (@DirectoryId, @Name, @Time, @Length);", files); } /// /// Finds all the files in a particular directory /// public Task GetFileAsync(int fileId) { return _connection.QuerySingleAsync("SELECT * FROM Files WHERE Id = @FileId", new { FileId = fileId }); } /// /// Finds all the files in a particular directory /// public Task> FindFilesInDirectoryAsync(int directoryId) { return _connection.QueryAsync("SELECT * FROM Files WHERE DirectoryId = @DirectoryId", new { DirectoryId = directoryId }); } /// /// Finds all the files in a particular directory /// public Task> FindFilesInDirectoriesAsync(IEnumerable directoryIds) { return _connection.QueryAsync("SELECT * FROM Files WHERE DirectoryId IN (@DirectoryIds)", new { DirectoryIds = directoryIds }); } /// /// Gets the full name of a file /// public async Task GetFilePathAsync(int id) { StringBuilder builder = new StringBuilder(); await GetFilePathAsync(id, builder); return builder.ToString(); } /// /// Gets the full name of a directory /// public async Task GetFilePathAsync(int id, StringBuilder builder) { FileRow file = await GetFileAsync(id); await GetDirectoryPathAsync(file.DirectoryId, builder); builder.Append(file.Name); } /// /// Removes a file and all its chunk metadata /// public async Task RemoveFileAsync(int fileId) { await _connection.ExecuteAsync("DELETE FROM Chunks WHERE FileId = @FileId; DELETE FROM Files WHERE FileId = @FileId;", fileId); } #endregion #region Chunks /// /// Adds a record for a new file chunk /// public async Task AddChunkAsync(int fileId, long offset, long length, IoHash hash) { ChunkRow row = new ChunkRow(fileId, offset, length, hash); return await _connection.ExecuteScalarAsync("INSERT INTO Chunks (FileId, Offset, Length, Hash) VALUES (@FileId, @Offset, @Length, @Hash); SELECT last_insert_rowid();", row); } /// /// Adds multiple file chunk records /// public async Task AddChunksAsync(IEnumerable chunks) { await _connection.ExecuteScalarAsync("INSERT INTO Chunks (FileId, Offset, Length, Hash) VALUES (@FileId, @Offset, @Length, @Hash);", chunks); } /// /// Gets a chunk row /// public async Task GetChunkAsync(int chunkId) { return await _connection.QuerySingleAsync("SELECT * FROM Chunks WHERE id = @ChunkId;", new { ChunkId = chunkId }); } /// /// Find all chunks with a particular hash and length /// public async Task> FindChunksAsync(IoHash hash, long length) { return await _connection.QueryAsync("SELECT * FROM Chunks WHERE hash = @Hash AND length = @Length;", new { Hash = hash, Length = length }); } /// /// Finds all the chunks within a particular file /// public async Task> FindChunksForFileAsync(int fileId) { return await _connection.QueryAsync("SELECT * FROM Chunks WHERE fileId = @FileId;", new { FileId = fileId }); } /// /// Remove all chunks for a particular file /// public async Task RemoveChunksForFileAsync(int fileId) { await _connection.ExecuteAsync("DELETE FROM Chunks WHERE FileId = @FileId; DELETE FROM Files WHERE FileId = @FileId;", fileId); } /// /// Remove all chunks for a set of files /// public async Task RemoveChunksForFilesAsync(IEnumerable fileIds) { await _connection.ExecuteAsync("DELETE FROM Chunks WHERE FileId IN (@FileIds);", new { FileIds = fileIds }); } #endregion #region Directories /// /// Adds a new directory to the collection /// public async Task AddDirectoryAsync(int parentDirectoryId, string name) { DirectoryRow row = new DirectoryRow { ParentDirectoryId = parentDirectoryId, Name = name }; return await _connection.ExecuteScalarAsync("INSERT INTO Directories (ParentDirectoryId, Name) VALUES (@ParentDirectoryId, @Name); SELECT last_insert_rowid();", row); } /// /// Adds multiple directories to the collection /// public async Task AddDirectoriesAsync(IEnumerable dirs) { await _connection.ExecuteAsync("INSERT INTO Directories (ParentDirectoryId, Name) VALUES (@ParentDirectoryId, @Name);", dirs); } /// /// Gets the definition for a particular directory /// public async Task GetDirectoryAsync(int id) { return await _connection.QuerySingleAsync("SELECT * FROM Directories WHERE Id = @Id;", new { Id = id }); } /// /// Gets the full name of a directory /// public async Task GetDirectoryPathAsync(int id) { StringBuilder builder = new StringBuilder(); await GetDirectoryPathAsync(id, builder); return builder.ToString(); } /// /// Gets the full name of a directory /// public async Task GetDirectoryPathAsync(int id, StringBuilder builder) { if (id != RootDirectoryId) { DirectoryRow row = await GetDirectoryAsync(id); await GetDirectoryPathAsync(row.ParentDirectoryId, builder); builder.Append(row.Name); builder.Append('/'); } } /// /// Finds all directories within a given parent directory /// public async Task> GetDirectoriesAsync(int parentDirectoryId) { return await _connection.QueryAsync("SELECT * FROM Directories WHERE ParentDirectoryId = @ParentDirectoryId;", new { ParentDirectoryId = parentDirectoryId }); } /// /// Removes a directory and all its subdirectories /// public async Task RemoveDirectoryAsync(int directoryId) { await RemoveDirectoryContentsAsync(directoryId); await _connection.ExecuteAsync("DELETE FROM Directories WHERE Id = @Id;", new { Id = directoryId }); } /// /// Removes a directory and all its subdirectories /// public async Task RemoveDirectoriesAsync(IEnumerable directoryIds) { await RemoveDirectoryContentsAsync(directoryIds); await _connection.ExecuteAsync("DELETE FROM Directories WHERE Id IN (@DirectoryIds);", new { DirectoryIds = directoryIds }); } /// /// Removes the contents of a directory, without removing the directory itself /// public async Task RemoveDirectoryContentsAsync(int directoryId) { await RemoveDirectoryContentsAsync(new[] { directoryId }); } /// /// Removes all subdirectories and files starting at the given roots /// public async Task RemoveDirectoryContentsAsync(IEnumerable directoryIds) { object directoryIdList = new { DirectoryIds = directoryIds }; IEnumerable subDirectoryIds = await _connection.QueryAsync("SELECT Id FROM Directories WHERE ParentDirectoryId IN (@DirectoryIds);", directoryIdList); if (subDirectoryIds.Any()) { await RemoveDirectoryContentsAsync(subDirectoryIds); await _connection.ExecuteAsync("DELETE FROM Directories WHERE ParentDirectoryId IN (@DirectoryIds);", directoryIdList); } IEnumerable fileIds = await _connection.QueryAsync("SELECT (Id) FROM Files WHERE DirectoryId IN (@DirectoryIds);", directoryIdList); if (fileIds.Any()) { await RemoveChunksForFilesAsync(fileIds); await _connection.ExecuteAsync("DELETE FROM Files WHERE DirectoryId IN (@DirectoryIds);", directoryIdList); } } #endregion } /// /// Metadata for a file /// /// Unique id for this file /// Identifier for the directory containing this file /// Name of the file /// Last modified timestamp for the file /// Length of the file public record class FileRow(int Id, int DirectoryId, string Name, DateTime Time, long Length) { /// /// Default constructor /// public FileRow() : this(-1, -1, String.Empty, DateTime.MinValue, 0) { } /// /// Constructor for new file rows /// public FileRow(int directoryId, string name, DateTime time, long length) : this(-1, directoryId, name, time, length) { } } /// /// Metadata for a directory /// /// Unique id for this directory /// Parent directory identifier /// Name of the directory public record class DirectoryRow(int Id, int ParentDirectoryId, string Name) { /// /// Default constructor /// public DirectoryRow() : this(-1, -1, String.Empty) { } /// /// Constructor for new directory rows /// public DirectoryRow(int parentDirectoryId, string name) : this(-1, parentDirectoryId, name) { } } /// /// Metadata for a file chunk /// /// Unique id for the row /// Id of the file that this chunk belongs to /// Starting offset within the file of this chunk /// Length of the chunk /// Hash of the chunk data public record class ChunkRow(int Id, int FileId, long Offset, long Length, IoHash Hash) { /// /// Default constructor /// public ChunkRow() : this(-1, -1, 0, 0, IoHash.Zero) { } /// /// Constructor for new chunk rows /// public ChunkRow(int fileId, long offset, long length, IoHash hash) : this(-1, fileId, offset, length, hash) { } } }