From 03f4c2bd0abe0dd5a861e28c2041d4bcdf419b78 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Thu, 21 May 2026 22:46:39 +0000 Subject: [PATCH] Add JsonSnapshotStateStore for durable state persistence Introduce JsonSnapshotStateStore implementing IStateStore to persist state to a JSON file on disk. The class uses an in-memory ConcurrentDictionary for fast access and ensures state survives process restarts by saving changes to a snapshot file. The default file path is %TEMP%/azlocal/state.json, configurable via AzLocal:SnapshotPath. Implemented methods include GetAsync, SetAsync, DeleteAsync, ListAsync, and ExistsAsync, with thread-safe file writes ensured via SemaphoreSlim. Added private helpers for loading and saving state. Includes XML documentation for clarity. --- src/AzLocal.State/JsonSnapshotStateStore.cs | 91 ++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) 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 }