feat: first pass at ReliableFileStorage service

This commit is contained in:
goat 2023-09-27 22:10:21 +02:00
parent f96ab7aa90
commit 125034155b
No known key found for this signature in database
GPG key ID: 49E2AA8C6A76498B
6 changed files with 379 additions and 13 deletions

View file

@ -7,6 +7,7 @@ using System.Linq;
using Dalamud.Game.Text; using Dalamud.Game.Text;
using Dalamud.Interface.Style; using Dalamud.Interface.Style;
using Dalamud.Plugin.Internal.Profiles; using Dalamud.Plugin.Internal.Profiles;
using Dalamud.Storage;
using Dalamud.Utility; using Dalamud.Utility;
using Newtonsoft.Json; using Newtonsoft.Json;
using Serilog; using Serilog;
@ -18,7 +19,7 @@ namespace Dalamud.Configuration.Internal;
/// Class containing Dalamud settings. /// Class containing Dalamud settings.
/// </summary> /// </summary>
[Serializable] [Serializable]
internal sealed class DalamudConfiguration : IServiceType internal sealed class DalamudConfiguration : IServiceType, IDisposable
{ {
private static readonly JsonSerializerSettings SerializerSettings = new() private static readonly JsonSerializerSettings SerializerSettings = new()
{ {
@ -422,23 +423,33 @@ internal sealed class DalamudConfiguration : IServiceType
/// <summary> /// <summary>
/// Load a configuration from the provided path. /// Load a configuration from the provided path.
/// </summary> /// </summary>
/// <param name="path">The path to load the configuration file from.</param> /// <param name="path">Path to read from.</param>
/// <param name="fs">File storage.</param>
/// <returns>The deserialized configuration file.</returns> /// <returns>The deserialized configuration file.</returns>
public static DalamudConfiguration Load(string path) public static DalamudConfiguration Load(string path, ReliableFileStorage fs)
{ {
DalamudConfiguration deserialized = null; DalamudConfiguration deserialized = null;
try try
{ {
deserialized = JsonConvert.DeserializeObject<DalamudConfiguration>(File.ReadAllText(path), SerializerSettings); fs.ReadAllText(path, text =>
{
deserialized =
JsonConvert.DeserializeObject<DalamudConfiguration>(text, SerializerSettings);
});
} }
catch (Exception ex) catch (FileNotFoundException)
{ {
Log.Warning(ex, "Failed to load DalamudConfiguration at {0}", path); // ignored
}
catch (Exception e)
{
Log.Error(e, "Could not load DalamudConfiguration at {Path}, creating new", path);
} }
deserialized ??= new DalamudConfiguration(); deserialized ??= new DalamudConfiguration();
deserialized.configPath = path; deserialized.configPath = path;
return deserialized; return deserialized;
} }
@ -457,6 +468,13 @@ internal sealed class DalamudConfiguration : IServiceType
{ {
this.Save(); this.Save();
} }
/// <inheritdoc/>
public void Dispose()
{
// Make sure that we save, if a save is queued while we are shutting down
this.Update();
}
/// <summary> /// <summary>
/// Save the file, if needed. Only needs to be done once a frame. /// Save the file, if needed. Only needs to be done once a frame.
@ -476,7 +494,8 @@ internal sealed class DalamudConfiguration : IServiceType
{ {
ThreadSafety.AssertMainThread(); ThreadSafety.AssertMainThread();
Util.WriteAllTextSafe(this.configPath, JsonConvert.SerializeObject(this, SerializerSettings)); Service<ReliableFileStorage>.Get().WriteAllText(
this.configPath, JsonConvert.SerializeObject(this, SerializerSettings));
this.DalamudConfigurationSaved?.Invoke(this); this.DalamudConfigurationSaved?.Invoke(this);
} }
} }

View file

@ -12,6 +12,7 @@ using Dalamud.Game;
using Dalamud.Game.Gui.Internal; using Dalamud.Game.Gui.Internal;
using Dalamud.Interface.Internal; using Dalamud.Interface.Internal;
using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal;
using Dalamud.Storage;
using Dalamud.Utility; using Dalamud.Utility;
using PInvoke; using PInvoke;
using Serilog; using Serilog;
@ -40,14 +41,15 @@ internal sealed class Dalamud : IServiceType
/// Initializes a new instance of the <see cref="Dalamud"/> class. /// Initializes a new instance of the <see cref="Dalamud"/> class.
/// </summary> /// </summary>
/// <param name="info">DalamudStartInfo instance.</param> /// <param name="info">DalamudStartInfo instance.</param>
/// <param name="fs">ReliableFileStorage instance.</param>
/// <param name="configuration">The Dalamud configuration.</param> /// <param name="configuration">The Dalamud configuration.</param>
/// <param name="mainThreadContinueEvent">Event used to signal the main thread to continue.</param> /// <param name="mainThreadContinueEvent">Event used to signal the main thread to continue.</param>
public Dalamud(DalamudStartInfo info, DalamudConfiguration configuration, IntPtr mainThreadContinueEvent) public Dalamud(DalamudStartInfo info, ReliableFileStorage fs, DalamudConfiguration configuration, IntPtr mainThreadContinueEvent)
{ {
this.unloadSignal = new ManualResetEvent(false); this.unloadSignal = new ManualResetEvent(false);
this.unloadSignal.Reset(); this.unloadSignal.Reset();
ServiceManager.InitializeProvidedServicesAndClientStructs(this, info, configuration); ServiceManager.InitializeProvidedServicesAndClientStructs(this, info, fs, configuration);
if (!configuration.IsResumeGameAfterPluginLoad) if (!configuration.IsResumeGameAfterPluginLoad)
{ {

View file

@ -77,6 +77,7 @@
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="sqlite-net-pcl" Version="1.8.116" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.333"> <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.333">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -10,6 +10,7 @@ using Dalamud.Configuration.Internal;
using Dalamud.Logging.Internal; using Dalamud.Logging.Internal;
using Dalamud.Logging.Retention; using Dalamud.Logging.Retention;
using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal;
using Dalamud.Storage;
using Dalamud.Support; using Dalamud.Support;
using Dalamud.Utility; using Dalamud.Utility;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -137,7 +138,8 @@ public sealed class EntryPoint
SerilogEventSink.Instance.LogLine += SerilogOnLogLine; SerilogEventSink.Instance.LogLine += SerilogOnLogLine;
// Load configuration first to get some early persistent state, like log level // Load configuration first to get some early persistent state, like log level
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!); var fs = new ReliableFileStorage(Path.GetDirectoryName(info.ConfigurationPath)!);
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs);
// Set the appropriate logging level from the configuration // Set the appropriate logging level from the configuration
if (!configuration.LogSynchronously) if (!configuration.LogSynchronously)
@ -169,7 +171,7 @@ public sealed class EntryPoint
if (!Util.IsWine()) if (!Util.IsWine())
InitSymbolHandler(info); InitSymbolHandler(info);
var dalamud = new Dalamud(info, configuration, mainThreadContinueEvent); var dalamud = new Dalamud(info, fs, configuration, mainThreadContinueEvent);
Log.Information("This is Dalamud - Core: {GitHash}, CS: {CsGitHash} [{CsVersion}]", Util.GetGitHash(), Util.GetGitHashClientStructs(), FFXIVClientStructs.Interop.Resolver.Version); Log.Information("This is Dalamud - Core: {GitHash}, CS: {CsGitHash} [{CsVersion}]", Util.GetGitHash(), Util.GetGitHashClientStructs(), FFXIVClientStructs.Interop.Resolver.Version);
dalamud.WaitForUnload(); dalamud.WaitForUnload();

View file

@ -11,6 +11,7 @@ using Dalamud.Configuration.Internal;
using Dalamud.Game; using Dalamud.Game;
using Dalamud.IoC.Internal; using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal; using Dalamud.Logging.Internal;
using Dalamud.Storage;
using Dalamud.Utility.Timing; using Dalamud.Utility.Timing;
using JetBrains.Annotations; using JetBrains.Annotations;
@ -83,8 +84,9 @@ internal static class ServiceManager
/// </summary> /// </summary>
/// <param name="dalamud">Instance of <see cref="Dalamud"/>.</param> /// <param name="dalamud">Instance of <see cref="Dalamud"/>.</param>
/// <param name="startInfo">Instance of <see cref="DalamudStartInfo"/>.</param> /// <param name="startInfo">Instance of <see cref="DalamudStartInfo"/>.</param>
/// <param name="fs">Instance of <see cref="ReliableFileStorage"/></param>
/// <param name="configuration">Instance of <see cref="DalamudConfiguration"/>.</param> /// <param name="configuration">Instance of <see cref="DalamudConfiguration"/>.</param>
public static void InitializeProvidedServicesAndClientStructs(Dalamud dalamud, DalamudStartInfo startInfo, DalamudConfiguration configuration) public static void InitializeProvidedServicesAndClientStructs(Dalamud dalamud, DalamudStartInfo startInfo, ReliableFileStorage fs, DalamudConfiguration configuration)
{ {
// Initialize the process information. // Initialize the process information.
var cacheDir = new DirectoryInfo(Path.Combine(startInfo.WorkingDirectory!, "cachedSigs")); var cacheDir = new DirectoryInfo(Path.Combine(startInfo.WorkingDirectory!, "cachedSigs"));
@ -98,6 +100,9 @@ internal static class ServiceManager
Service<DalamudStartInfo>.Provide(startInfo); Service<DalamudStartInfo>.Provide(startInfo);
LoadedServices.Add(typeof(DalamudStartInfo)); LoadedServices.Add(typeof(DalamudStartInfo));
Service<ReliableFileStorage>.Provide(fs);
LoadedServices.Add(typeof(ReliableFileStorage));
Service<DalamudConfiguration>.Provide(configuration); Service<DalamudConfiguration>.Provide(configuration);
LoadedServices.Add(typeof(DalamudConfiguration)); LoadedServices.Add(typeof(DalamudConfiguration));

View file

@ -0,0 +1,337 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Logging.Internal;
using PInvoke;
using SQLite;
namespace Dalamud.Storage;
/*
* TODO: A file that is read frequently, but written very rarely, might not have offline changes by users persisted
* into the backup database, since it is only written to the backup database when it is written to the filesystem.
*/
/// <summary>
/// A service that provides a reliable file storage.
/// Implements a VFS that writes files to the disk, and additionally keeps files in a SQLite database
/// for journaling/backup purposes.
/// Consumers can choose to receive a backup if they think that the file is corrupt.
/// </summary>
/// <remarks>
/// This is not an early-loaded service, as it is needed before they are initialized.
/// </remarks>
public class ReliableFileStorage : IServiceType, IDisposable
{
private static readonly ModuleLog Log = new("VFS");
private SQLiteConnection db;
/// <summary>
/// Initializes a new instance of the <see cref="ReliableFileStorage"/> class.
/// </summary>
/// <param name="vfsDbPath">Path to the VFS.</param>
[ServiceManager.ServiceConstructor]
public ReliableFileStorage(string vfsDbPath)
{
var databasePath = Path.Combine(vfsDbPath, "dalamudVfs.db");
Log.Verbose("Initializing VFS database at {Path}", databasePath);
this.db = new SQLiteConnection(databasePath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex);
this.db.CreateTable<DbFile>();
}
/// <summary>
/// Check if a file exists.
/// This will return true if the file does not exist on the filesystem, but in the transparent backup.
/// You must then use this instance to read the file to ensure consistency.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="containerId">The container to check in.</param>
/// <returns>True if the file exists.</returns>
public bool Exists(string path, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
if (File.Exists(path))
return true;
// If the file doesn't actually exist on the FS, but it does in the DB, we can say YES and read operations will read from the DB instead
var normalizedPath = NormalizePath(path);
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
return file != null;
}
/// <summary>
/// Write all text to a file.
/// </summary>
/// <param name="path">Path to write to.</param>
/// <param name="contents">The contents of the file.</param>
/// <param name="containerId">Container to write to.</param>
public void WriteAllText(string path, string? contents, Guid containerId = default)
=> this.WriteAllText(path, contents, Encoding.UTF8, containerId);
/// <summary>
/// Write all text to a file.
/// </summary>
/// <param name="path">Path to write to.</param>
/// <param name="contents">The contents of the file.</param>
/// <param name="encoding">The encoding to write with.</param>
/// <param name="containerId">Container to write to.</param>
public void WriteAllText(string path, string? contents, Encoding encoding, Guid containerId = default)
{
var bytes = encoding.GetBytes(contents ?? string.Empty);
this.WriteAllBytes(path, bytes, containerId);
}
/// <summary>
/// Write all bytes to a file.
/// </summary>
/// <param name="path">Path to write to.</param>
/// <param name="bytes">The contents of the file.</param>
/// <param name="containerId">Container to write to.</param>
public void WriteAllBytes(string path, byte[] bytes, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
var normalizedPath = NormalizePath(path);
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
if (file == null)
{
file = new DbFile
{
ContainerId = containerId,
Path = normalizedPath,
Data = bytes,
};
this.db.Insert(file);
}
else
{
file.Data = bytes;
this.db.Update(file);
}
WriteFileReliably(path, bytes);
}
/// <summary>
/// Read all text from a file.
/// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not
/// automatically written back to disk, however.
/// </summary>
/// <param name="path">The path to read from.</param>
/// <param name="forceBackup">Whether or not the backup of the file should take priority.</param>
/// <param name="containerId">The container to read from.</param>
/// <returns>All text stored in this file.</returns>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
public string ReadAllText(string path, bool forceBackup = false, Guid containerId = default)
=> this.ReadAllText(path, Encoding.UTF8, forceBackup, containerId);
/// <summary>
/// Read all text from a file.
/// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not
/// automatically written back to disk, however.
/// </summary>
/// <param name="path">The path to read from.</param>
/// <param name="encoding">The encoding to read with.</param>
/// <param name="forceBackup">Whether or not the backup of the file should take priority.</param>
/// <param name="containerId">The container to read from.</param>
/// <returns>All text stored in this file.</returns>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
public string ReadAllText(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default)
{
var bytes = this.ReadAllBytes(path, forceBackup, containerId);
return encoding.GetString(bytes);
}
/// <summary>
/// Read all text from a file, and automatically try again with the backup if the file does not exist or
/// the <paramref name="reader"/> function throws an exception. If the backup read also throws an exception,
/// or the file does not exist in the backup, a <see cref="FileReadException"/> is thrown.
/// </summary>
/// <param name="path">The path to read from.</param>
/// <param name="reader">Lambda that reads the file. Throw here to automatically attempt a read from the backup.</param>
/// <param name="containerId">The container to read from.</param>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
/// <exception cref="FileReadException">Thrown here if the file and the backup fail their read.</exception>
public void ReadAllText(string path, Action<string> reader, Guid containerId = default)
=> this.ReadAllText(path, Encoding.UTF8, reader, containerId);
/// <summary>
/// Read all text from a file, and automatically try again with the backup if the file does not exist or
/// the <paramref name="reader"/> function throws an exception. If the backup read also throws an exception,
/// or the file does not exist in the backup, a <see cref="FileReadException"/> is thrown.
/// </summary>
/// <param name="path">The path to read from.</param>
/// <param name="encoding">The encoding to read with.</param>
/// <param name="reader">Lambda that reads the file. Throw here to automatically attempt a read from the backup.</param>
/// <param name="containerId">The container to read from.</param>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
/// <exception cref="FileReadException">Thrown here if the file and the backup fail their read.</exception>
public void ReadAllText(string path, Encoding encoding, Action<string> reader, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
// TODO: We are technically reading one time too many here, if the file does not exist on the FS, ReadAllText
// fails over to the backup, and then the backup fails to read in the lambda. We should do something about that,
// but it's not a big deal. Would be nice if ReadAllText could indicate if it did fail over.
// 1.) Try without using the backup
try
{
var text = this.ReadAllText(path, encoding, false, containerId);
reader(text);
return;
}
catch (FileNotFoundException)
{
// We can't do anything about this.
throw;
}
catch (Exception ex)
{
Log.Verbose(ex, "First chance read from {Path} failed, trying backup", path);
}
// 2.) Try using the backup
try
{
var text = this.ReadAllText(path, encoding, true, containerId);
reader(text);
}
catch (Exception ex)
{
Log.Error(ex, "Second chance read from {Path} failed, giving up", path);
throw new FileReadException(ex);
}
}
/// <summary>
/// Read all bytes from a file.
/// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not
/// automatically written back to disk, however.
/// </summary>
/// <param name="path">The path to read from.</param>
/// <param name="forceBackup">Whether or not the backup of the file should take priority.</param>
/// <param name="containerId">The container to read from.</param>
/// <returns>All bytes stored in this file.</returns>
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
public byte[] ReadAllBytes(string path, bool forceBackup = false, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
if (forceBackup)
{
var normalizedPath = NormalizePath(path);
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
if (file == null)
throw new FileNotFoundException();
return file.Data;
}
// If the file doesn't exist, immediately check the backup db
if (!File.Exists(path))
return this.ReadAllBytes(path, true, containerId);
try
{
return File.ReadAllBytes(path);
}
catch (Exception e)
{
Log.Error(e, "Failed to read file from disk, falling back to database");
return this.ReadAllBytes(path, true, containerId);
}
}
/// <inheritdoc/>
public void Dispose()
{
this.db.Dispose();
}
private static void WriteFileReliably(string path, byte[] bytes)
{
ArgumentException.ThrowIfNullOrEmpty(path);
// Open the temp file
var tempPath = path + ".tmp";
using var tempFile = Kernel32
.CreateFile(tempPath.AsSpan(),
new Kernel32.ACCESS_MASK(Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE),
Kernel32.FileShare.None,
null,
Kernel32.CreationDisposition.CREATE_ALWAYS,
Kernel32.CreateFileFlags.FILE_ATTRIBUTE_NORMAL,
Kernel32.SafeObjectHandle.Null);
if (tempFile.IsInvalid)
throw new Win32Exception();
// Write the data
var bytesWritten = Kernel32.WriteFile(tempFile, new ArraySegment<byte>(bytes));
if (bytesWritten != bytes.Length)
throw new Exception($"Could not write all bytes to temp file ({bytesWritten} of {bytes.Length})");
if (!Kernel32.FlushFileBuffers(tempFile))
throw new Win32Exception();
tempFile.Close();
if (!MoveFileEx(tempPath, path, MoveFileFlags.MovefileReplaceExisting | MoveFileFlags.MovefileWriteThrough))
throw new Win32Exception();
}
/// <summary>
/// Replace possible non-portable parts of a path with portable versions.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
private static string NormalizePath(string path)
{
// Replace users folder
var usersFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
path = path.Replace(usersFolder, "%USERPROFILE%");
return path;
}
[Flags]
#pragma warning disable SA1201
private enum MoveFileFlags
#pragma warning restore SA1201
{
MovefileReplaceExisting = 0x00000001,
MovefileWriteThrough = 0x00000008,
}
[return: MarshalAs(UnmanagedType.Bool)]
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
private static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName,
MoveFileFlags dwFlags);
private class DbFile
{
[PrimaryKey]
[AutoIncrement]
public int Id { get; set; }
public Guid ContainerId { get; set; }
public string Path { get; set; } = null!;
public byte[] Data { get; set; } = null!;
}
}
public class FileReadException : Exception
{
public FileReadException(Exception inner)
: base("Failed to read file", inner)
{
}
}