AccountingNext/Schaad.Accounting.Db/Repositories/BaseRepository.cs
Claudio Schaad 1c7ba7f416 PR D: per-scope repository cache
Add a Scoped RepositoryCache that memoizes deserialized XML lists,
keyed by absolute file path. Each XML file is loaded at most once per
SignalR circuit; Save invalidates the corresponding entry so
subsequent reads reload from disk.

BaseRepository gains a LoadList<T>(fileName) helper; each of the seven
repositories now defines Get*List() as a one-liner over that helper
and takes RepositoryCache via the constructor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:52:24 +02:00

121 lines
No EOL
4.1 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
using System.Xml.Serialization;
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<string, object> FileLocks =
new(StringComparer.OrdinalIgnoreCase);
protected readonly ISettingsService settingsService;
protected readonly RepositoryCache cache;
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache)
{
this.settingsService = settingsService;
this.cache = cache;
}
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);
}
}
}
}
/// <summary>
/// Load a list, cached per scope. First call reads the XML file; subsequent calls in
/// the same scope return the cached list. <see cref="Save"/> invalidates the entry.
/// </summary>
protected List<T> LoadList<T>(string fileName)
{
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
return cache.GetOrLoad(filePath, () => Load<List<T>>(fileName) ?? new List<T>());
}
/// <summary>
/// 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.
/// </summary>
protected void Save<T>(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
{
if (File.Exists(tmpPath))
{
try { File.Delete(tmpPath); } catch { /* best effort */ }
}
throw;
}
}
cache.Invalidate(filePath);
}
/// <summary>
/// Load an object from an xml file (uncached; used for one-off reads such as file
/// existence bootstrapping). Prefer <see cref="LoadList{T}"/> for repository reads.
/// </summary>
protected T Load<T>(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());
}
}