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();
}
}
}