AccountingNext/Schaad.Accounting.Db/Repositories/BaseRepository.cs
Claudio Schaad 3a770af016 PR J: log repository save failures via ILogger
BaseRepository takes an ILogger via its constructor and logs the
failing file path and exception in Save's catch block before
rethrowing. Every concrete repository takes ILogger<ThisRepo> and
passes it up so failures are categorised by repository type.

Pulls Microsoft.Extensions.Logging.Abstractions into the Db project.
TransactionRepositoryTestShould and AccountRepositoryTestShould pass
NullLogger<T>.Instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:47:55 +02:00

125 lines
No EOL
4.4 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 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<string, object> 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);
}
}
}
}
/// <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 (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);
}
/// <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());
}
}