diff --git a/src/AzLocal.State/JsonSnapshotStateStore.cs b/src/AzLocal.State/JsonSnapshotStateStore.cs index 71dfcce..dd2f758 100644 --- a/src/AzLocal.State/JsonSnapshotStateStore.cs +++ b/src/AzLocal.State/JsonSnapshotStateStore.cs @@ -1,5 +1,92 @@ -namespace AzLocal.State; +using AzLocal.Core.Interfaces; +using Microsoft.Extensions.Configuration; +using System.Collections.Concurrent; +using System.Text.Json; -public class JsonSnapshotStateStore +namespace AzLocal.State; + +/// +/// Same in-memory dictionary as , but every write is also +/// persisted to a JSON file on disk. State survives process restarts. +/// Snapshot file defaults to %TEMP%/azlocal/state.json unless overridden via +/// AzLocal:SnapshotPath in configuration. +/// +public class JsonSnapshotStateStore : IStateStore { + private readonly string _filePath; + + // Limits concurrent file writes to one at a time so the snapshot is never half-written. + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly ConcurrentDictionary _store; + + public JsonSnapshotStateStore(IConfiguration config) + { + var dir = config["AzLocal:SnapshotPath"] + ?? Path.Combine(Path.GetTempPath(), "azlocal"); + Directory.CreateDirectory(dir); + _filePath = Path.Combine(dir, "state.json"); + _store = Load(); + } + + /// Returns the entry deserialized as , or null if the key does not exist. + public Task GetAsync(string key) + { + if (_store.TryGetValue(key, out var json)) + return Task.FromResult(JsonSerializer.Deserialize(json)); + return Task.FromResult(default); + } + + /// Serializes to JSON, stores it in memory, and flushes the snapshot to disk. + public async Task SetAsync(string key, T value) + { + _store[key] = JsonSerializer.Serialize(value); + await SaveAsync(); + } + + /// Removes the entry with the given key and flushes the snapshot to disk. No-ops if the key does not exist. + public async Task DeleteAsync(string key) + { + _store.TryRemove(key, out _); + await SaveAsync(); + } + + /// Returns all entries whose keys start with , deserialized as . + public Task> ListAsync(string prefix) + { + var results = _store + .Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(kv => JsonSerializer.Deserialize(kv.Value)!) + .ToList(); + return Task.FromResult>(results); + } + + /// Returns true if an entry with the given key exists. + public Task ExistsAsync(string key) => + Task.FromResult(_store.ContainsKey(key)); + + #region private helpers + + // Reads the snapshot file on startup. Returns an empty dictionary if the file does not exist yet. + private ConcurrentDictionary Load() + { + if (!File.Exists(_filePath)) + return new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var json = File.ReadAllText(_filePath); + var dict = JsonSerializer.Deserialize>(json) ?? new(); + return new ConcurrentDictionary(dict, StringComparer.OrdinalIgnoreCase); + } + + // Writes the full dictionary to disk under the semaphore so concurrent writes don't race. + private async Task SaveAsync() + { + await _lock.WaitAsync(); + try + { + await File.WriteAllTextAsync(_filePath, + JsonSerializer.Serialize(new Dictionary(_store))); + } + finally { _lock.Release(); } + } + + #endregion }