From 05648f019be5365f1c98479da67428e83192711f Mon Sep 17 00:00:00 2001 From: goaaats Date: Tue, 18 Nov 2025 20:37:57 +0100 Subject: [PATCH] First draft of IReliableFileStorage service --- .../Plugin/Services/IReliableFileStorage.cs | 163 ++++++++++++++++++ .../ReliableFileStoragePluginScoped.cs | 120 +++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 Dalamud/Plugin/Services/IReliableFileStorage.cs create mode 100644 Dalamud/Storage/ReliableFileStoragePluginScoped.cs diff --git a/Dalamud/Plugin/Services/IReliableFileStorage.cs b/Dalamud/Plugin/Services/IReliableFileStorage.cs new file mode 100644 index 000000000..3a3c070f0 --- /dev/null +++ b/Dalamud/Plugin/Services/IReliableFileStorage.cs @@ -0,0 +1,163 @@ +using System.IO; +using System.Text; +using System.Threading.Tasks; + +using Dalamud.Storage; + +namespace Dalamud.Plugin.Services; + +/// +/// Service to interact with the file system, as a replacement for standard C# file I/O. +/// Writes and reads using this service are, to the best of our ability, atomic and reliable. +/// +/// All data is synced to disk immediately and written to a database, additionally to files on disk. This means +/// that in case of file corruption, data can likely be recovered from the database. +/// +/// However, this also means that operations using this service duplicate data on disk, so we don't recommend +/// performing large file operations. The service will not permit files larger than +/// (64MB) to be written. +/// +public interface IReliableFileStorage : IDalamudService +{ + /// + /// Gets the maximum file size, in bytes, that can be written using this service. + /// + /// + /// The service enforces this limit when writing files and fails with an appropriate exception + /// (for example or a custom exception) when a caller attempts to write + /// more than this number of bytes. + /// + long MaxFileSizeBytes { get; } + + /// + /// Check whether a file exists either on the local filesystem or in the transparent backup database. + /// + /// The file system path to check. Must not be null or empty. + /// + /// True if the file exists on disk or a backup copy exists in the storage's internal journal/backup database; + /// otherwise false. + /// + /// Thrown when is null or empty. + bool Exists(string path); + + /// + /// Write the given text into a file using UTF-8 encoding. The write is performed atomically and is persisted to + /// both the filesystem and the internal backup database used by this service. + /// + /// The file path to write to. Must not be null or empty. + /// The string contents to write. May be null, in which case an empty file is written. + /// A that completes when the write has finished and been flushed to disk and the backup. + /// Thrown when is null or empty. + Task WriteAllTextAsync(string path, string? contents); + + /// + /// Write the given text into a file using the provided . The write is performed + /// atomically (to the extent possible) and is persisted to both the filesystem and the internal backup database + /// used by this service. + /// + /// The file path to write to. Must not be null or empty. + /// The string contents to write. May be null, in which case an empty file is written. + /// The text encoding to use when serializing the string to bytes. Must not be null. + /// A that completes when the write has finished and been flushed to disk and the backup. + /// Thrown when is null or empty. + /// Thrown when is null. + Task WriteAllTextAsync(string path, string? contents, Encoding encoding); + + /// + /// Write the given bytes to a file. The write is persisted to both the filesystem and the service's internal + /// backup database. Avoid writing extremely large byte arrays because this service duplicates data on disk. + /// + /// The file path to write to. Must not be null or empty. + /// The raw bytes to write. Must not be null. + /// A that completes when the write has finished and been flushed to disk and the backup. + /// Thrown when is null or empty. + /// Thrown when is null. + Task WriteAllBytesAsync(string path, byte[] bytes); + + /// + /// Read all text from a file using UTF-8 encoding. If the file is unreadable or missing on disk, the service + /// attempts to return a backed-up copy from its internal journal/backup database. + /// + /// The file path to read. Must not be null or empty. + /// + /// When true the service prefers the internal backup database and returns backed-up contents if available. When + /// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing. + /// + /// The textual contents of the file, decoded using UTF-8. + /// Thrown when is null or empty. + /// Thrown when the file does not exist on disk and no backup copy is available. + Task ReadAllTextAsync(string path, bool forceBackup = false); + + /// + /// Read all text from a file using the specified . If the file is unreadable or + /// missing on disk, the service attempts to return a backed-up copy from its internal journal/backup database. + /// + /// The file path to read. Must not be null or empty. + /// The encoding to use when decoding the stored bytes into text. Must not be null. + /// + /// When true the service prefers the internal backup database and returns backed-up contents if available. When + /// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing. + /// + /// The textual contents of the file decoded using the provided . + /// Thrown when is null or empty. + /// Thrown when is null. + /// Thrown when the file does not exist on disk and no backup copy is available. + Task ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false); + + /// + /// Read all text from a file and invoke the provided callback with the string + /// contents. If the reader throws or the initial read fails, the service attempts a backup read and invokes the + /// reader again with the backup contents. If both reads fail the service surfaces an exception to the caller. + /// + /// The file path to read. Must not be null or empty. + /// + /// A callback invoked with the file's textual contents. Must not be null. + /// If the callback throws an exception the service treats that as a signal to retry the read using the + /// internal backup database and will invoke the callback again with the backup contents when available. + /// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of + /// silently accepting corrupt data. + /// + /// A that completes when the read (and any attempted fallback) and callback invocation have finished. + /// Thrown when is null or empty. + /// Thrown when is null. + /// Thrown when the file does not exist on disk and no backup copy is available. + /// Thrown when both the filesystem read and the backup read fail for other reasons. + Task ReadAllTextAsync(string path, Action reader); + + /// + /// Read all text from a file using the specified and invoke the provided + /// callback with the decoded string contents. If the reader throws or the initial + /// read fails, the service attempts a backup read and invokes the reader again with the backup contents. If + /// both reads fail the service surfaces an exception to the caller. + /// + /// The file path to read. Must not be null or empty. + /// The encoding to use when decoding the stored bytes into text. Must not be null. + /// + /// A callback invoked with the file's textual contents. Must not be null. + /// If the callback throws an exception the service treats that as a signal to retry the read using the + /// internal backup database and will invoke the callback again with the backup contents when available. + /// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of + /// silently accepting corrupt data. + /// + /// A that completes when the read (and any attempted fallback) and callback invocation have finished. + /// Thrown when is null or empty. + /// Thrown when or is null. + /// Thrown when the file does not exist on disk and no backup copy is available. + /// Thrown when both the filesystem read and the backup read fail for other reasons. + Task ReadAllTextAsync(string path, Encoding encoding, Action reader); + + /// + /// Read all bytes from a file. If the file is unreadable or missing on disk, the service may try to return a + /// backed-up copy from its internal journal/backup database. + /// + /// The file path to read. Must not be null or empty. + /// + /// When true the service prefers the internal backup database and returns the backed-up contents + /// if available. When false the service tries the filesystem first and falls back to the backup only + /// on error or when the file is missing. + /// + /// The raw bytes stored in the file. + /// Thrown when is null or empty. + /// Thrown when the file does not exist on disk and no backup copy is available. + Task ReadAllBytesAsync(string path, bool forceBackup = false); +} diff --git a/Dalamud/Storage/ReliableFileStoragePluginScoped.cs b/Dalamud/Storage/ReliableFileStoragePluginScoped.cs new file mode 100644 index 000000000..1f1992da6 --- /dev/null +++ b/Dalamud/Storage/ReliableFileStoragePluginScoped.cs @@ -0,0 +1,120 @@ +using System.Threading.Tasks; +using System.Text; + +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; + +namespace Dalamud.Storage; + +[PluginInterface] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +public class ReliableFileStoragePluginScoped : IReliableFileStorage, IServiceType +{ + // TODO: Make sure pending writes are finalized on plugin unload? + + private readonly LocalPlugin plugin; + + [ServiceManager.ServiceDependency] + private readonly ReliableFileStorage storage = Service.Get(); + + [ServiceManager.ServiceConstructor] + internal ReliableFileStoragePluginScoped(LocalPlugin plugin) + { + this.plugin = plugin; + } + + /// + public long MaxFileSizeBytes => 64 * 1024 * 1024; + + /// + public bool Exists(string path) + { + return this.storage.Exists(path, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task WriteAllTextAsync(string path, string? contents) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + var bytes = Encoding.UTF8.GetBytes(contents ?? string.Empty); + if (bytes.LongLength > this.MaxFileSizeBytes) + throw new ArgumentException($"The provided data exceeds the maximum allowed size of {this.MaxFileSizeBytes} bytes.", nameof(contents)); + + return this.storage.WriteAllBytesAsync(path, bytes, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task WriteAllTextAsync(string path, string? contents, Encoding encoding) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(encoding); + + var bytes = encoding.GetBytes(contents ?? string.Empty); + if (bytes.LongLength > this.MaxFileSizeBytes) + throw new ArgumentException($"The provided data exceeds the maximum allowed size of {this.MaxFileSizeBytes} bytes.", nameof(contents)); + + return this.storage.WriteAllBytesAsync(path, bytes, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task WriteAllBytesAsync(string path, byte[] bytes) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(bytes); + + if (bytes.LongLength > this.MaxFileSizeBytes) + throw new ArgumentException($"The provided data exceeds the maximum allowed size of {this.MaxFileSizeBytes} bytes.", nameof(bytes)); + + return this.storage.WriteAllBytesAsync(path, bytes, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, bool forceBackup = false) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + return this.storage.ReadAllTextAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(encoding); + + return this.storage.ReadAllTextAsync(path, encoding, forceBackup, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, Action reader) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(reader); + + return this.storage.ReadAllTextAsync(path, reader, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, Encoding encoding, Action reader) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(encoding); + ArgumentNullException.ThrowIfNull(reader); + + return this.storage.ReadAllTextAsync(path, encoding, reader, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllBytesAsync(string path, bool forceBackup = false) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + return this.storage.ReadAllBytesAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId); + } +}