using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; using Microsoft.Extensions.Logging; using Schaad.Accounting.Interfaces; namespace Schaad.Accounting.Repositories { public abstract class BaseRepository { // One semaphore per absolute file path so concurrent SaveAsync+SaveAsync and // SaveAsync+LoadAsync are serialized and can't observe a half-written file. private static readonly ConcurrentDictionary FileLocks = new(StringComparer.OrdinalIgnoreCase); protected readonly ISettingsService settingsService; protected readonly RepositoryCache cache; protected readonly ILogger logger; protected BaseRepository(ISettingsService settingsService, RepositoryCache cache, ILogger logger) { this.settingsService = settingsService; this.cache = cache; this.logger = logger; } /// /// Copies fileName from last year's directory to the current year's directory when the /// current year doesn't have it yet. Kept synchronous because it runs from repository /// constructors; File.Copy has no async equivalent in .NET 9 either way. /// protected void EnsureFileExists(string fileName) { string filePath = Path.Combine(settingsService.GetDbPath(), fileName); var semaphore = GetLock(filePath); semaphore.Wait(); try { if (File.Exists(filePath) == false) { var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName); if (File.Exists(lastYearFile)) { File.Copy(lastYearFile, filePath); } } } finally { semaphore.Release(); } } /// /// Load a list, cached per scope. First call reads the XML file; subsequent calls in /// the same scope return the cached list. invalidates the entry. /// protected Task> LoadListAsync(string fileName) { var filePath = Path.Combine(settingsService.GetDbPath(), fileName); return cache.GetOrLoadAsync(filePath, async () => await LoadAsync>(fileName) ?? new List()); } /// /// Save an object to an xml file. Writes to a .tmp sibling and then atomically /// renames it, so a crash mid-write leaves the previous file intact. Invalidates /// the corresponding cache entry. /// protected async Task SaveAsync(T obj, string fileName) { var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var tmpPath = filePath + ".tmp"; var semaphore = GetLock(filePath); await semaphore.WaitAsync(); try { var settings = new XmlWriterSettings { Encoding = Encoding.UTF8, Indent = true }; try { // XmlSerializer is synchronous — serialize to memory, then write async. using var memory = new MemoryStream(); using (var writer = XmlWriter.Create(memory, settings)) { var serializer = new XmlSerializer(typeof(T)); serializer.Serialize(writer, obj); } await File.WriteAllBytesAsync(tmpPath, memory.ToArray()); // File.Move has no async form in .NET 9. File.Move(tmpPath, filePath, overwrite: true); } catch (Exception ex) { logger.LogError(ex, "Failed to save {FilePath}", filePath); if (File.Exists(tmpPath)) { try { File.Delete(tmpPath); } catch { /* best effort */ } } throw; } } finally { semaphore.Release(); } cache.Invalidate(filePath); } /// /// Load an object from an xml file (uncached; used for one-off reads such as file /// existence bootstrapping). Prefer for repository reads. /// protected async Task LoadAsync(string fileName) { var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var semaphore = GetLock(filePath); await semaphore.WaitAsync(); try { if (File.Exists(filePath) == false) { return default(T); } var bytes = await File.ReadAllBytesAsync(filePath); using var memory = new MemoryStream(bytes); var serializer = new XmlSerializer(typeof(T)); return (T)serializer.Deserialize(memory); } finally { semaphore.Release(); } } private static SemaphoreSlim GetLock(string filePath) => FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); } }