// 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)
{ }
}
}