using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Text; using System.Xml; using System.Xml.Serialization; using Microsoft.Extensions.Logging; using Schaad.Accounting.Interfaces; namespace Schaad.Accounting.Repositories { public abstract class BaseRepository { // One lock per absolute file path so concurrent Save+Save and Save+Load 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; } protected void EnsureFileExists(string fileName) { string filePath = Path.Combine(settingsService.GetDbPath(), fileName); lock (GetLock(filePath)) { if (File.Exists(filePath) == false) { var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName); if (File.Exists(lastYearFile)) { File.Copy(lastYearFile, filePath); } } } } /// /// 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 List LoadList(string fileName) { var filePath = Path.Combine(settingsService.GetDbPath(), fileName); return cache.GetOrLoad(filePath, () => Load>(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 void Save(T obj, string fileName) { var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var tmpPath = filePath + ".tmp"; lock (GetLock(filePath)) { var settings = new XmlWriterSettings { Encoding = Encoding.UTF8, Indent = true }; try { using (var writer = XmlWriter.Create(tmpPath, settings)) { var serializer = new XmlSerializer(typeof(T)); serializer.Serialize(writer, obj); } 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; } } 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 T Load(string fileName) { var filePath = Path.Combine(settingsService.GetDbPath(), fileName); lock (GetLock(filePath)) { if (File.Exists(filePath) == false) { return default(T); } using (XmlReader reader = XmlReader.Create(filePath)) { var serializer = new XmlSerializer(typeof(T)); return (T)serializer.Deserialize(reader); } } } private static object GetLock(string filePath) => FileLocks.GetOrAdd(filePath, _ => new object()); } }