using System.Collections.Concurrent; using System.Text; using System.Text.Json; using AIStudio.Settings; namespace AIStudio.Assistants.ModelComparison; /// /// Stores the files of the Model Comparison assistant below the data directory. /// /// /// The store keeps no paths: it derives every path from IDs, so a backup works on another computer as well. It writes /// each file atomically, guards each file with a lock of its own, and never deletes anything on its own; only the user /// deletes. A file this version cannot read is left alone rather than overwritten or removed. /// /// The logger. /// Overrides the data directory, for tests. public sealed partial class ModelComparisonStore(ILogger logger, ModelComparisonStorageOptions? storageOptions = null) { /// /// The name of the folder below the data directory. /// public const string ROOT_DIRECTORY_NAME = "modelComparisons"; /// /// The JSON options of all files of the assistant. /// private static readonly JsonSerializerOptions JSON_OPTIONS = ModelComparisonJson.PERSISTENCE; /// /// The lock of each file, by the ID which names it. /// private readonly ConcurrentDictionary fileLocks = []; /// /// Serializes the first start of the store. /// private readonly SemaphoreSlim initializationLock = new(1, 1); /// /// Whether the store cleaned up already. /// private bool initialized; /// /// Gets the folder of the assistant below the data directory. /// private string RootDirectory => Path.Combine( storageOptions?.DataDirectory ?? SettingsManager.DataDirectory ?? throw new InvalidOperationException("The AI Studio data directory is not initialized."), ROOT_DIRECTORY_NAME); /// /// Reads a file of the store. /// /// The type of the file. /// The file. /// The cancellation token. /// What the file holds, or null when it does not exist or this version cannot read it. private async Task ReadJsonAsync(string path, CancellationToken token) where T : class { if (!File.Exists(path)) return null; try { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); return await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { logger.LogWarning(exception, "Could not read the file '{Path}' of the Model Comparison assistant.", path); return null; } } /// /// Writes a file of the store so that readers see either the old or the new content, never a part of it. /// /// The type of the file. /// The file to write. /// The new content. /// The cancellation token. /// The JSON options, those of the assistant by default; a chat takes those of the workspaces. private static async Task WriteJsonAtomicAsync(string targetPath, T value, CancellationToken token, JsonSerializerOptions? options = null) { Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}"; try { await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(value, options ?? JSON_OPTIONS), new UTF8Encoding(false), token); await using (var stream = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 4_096, true)) await stream.FlushAsync(token); File.Move(temporaryPath, targetPath, overwrite: true); } finally { TryDeleteFile(temporaryPath); } } /// /// Deletes a file when it exists, without failing. /// /// The file to delete. private static void TryDeleteFile(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { // Cleaning up a temporary file is best effort; the next start tries again. } } /// /// Gets the lock of a file. /// /// The ID which names the file. private SemaphoreSlim GetLock(Guid id) => this.fileLocks.GetOrAdd(id, _ => new(1, 1)); /// /// Drops the lock of a file which does not exist anymore. /// /// /// The semaphore is not disposed: another operation might still wait on it. /// /// The ID which named the file. private void ForgetLock(Guid id) => this.fileLocks.TryRemove(id, out _); /// /// Creates the folder of the assistant and removes temporary files a crash left behind. /// /// The cancellation token. private async Task InitializeAsync(CancellationToken token) { if (this.initialized) return; await this.initializationLock.WaitAsync(token); try { if (this.initialized) return; Directory.CreateDirectory(this.RootDirectory); foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories)) TryDeleteFile(temporaryPath); this.initialized = true; } finally { this.initializationLock.Release(); } } }