AccountingNext/Schaad.Accounting.Db/Repositories/BaseRepository.cs
Claudio Schaad f9084dc074 PR P: full async I/O top to bottom (item 19)
Every repository, service, Blazor page/dialog, and test now uses
async/await. Single atomic diff; the codebase does not compile in
intermediate states.

- BaseRepository: LoadListAsync/LoadAsync/SaveAsync return Task<T>;
  per-file locks use SemaphoreSlim so waiters can await; Save
  serialises to a MemoryStream sync (XmlSerializer has no async
  form), then File.WriteAllBytesAsync + sync File.Move.
- RepositoryCache.GetOrLoadAsync takes a Func<Task<List<T>>>.
- All 7 repository interfaces + implementations async.
- All service interfaces + implementations async (except vendor
  IFxService and stateless IFxConverter / SettingsService).
- Every Blazor OnInitializedAsync switches to await base.
- Test suite fully async, 42 tests pass.

AccountRepository.EnsureAccountsFile keeps two .GetAwaiter().GetResult()
bridges because it runs from the constructor.

Null-render guard follow-up (folded in):
Blazor now renders the component once with fields at their initial
values while OnInitializedAsync awaits — so fields declared `= null!`
are actually null on that first render and things like
`accounts.GroupBy(...)` throw ArgumentNullException. Fixed across
Transactions, BalanceReport, BalanceSheetReport, ProfitLossReport,
DetailReport, Assets, Spendings, SpendingsOverTime, TransactionDialog,
TransactionSplitDialog, and BookingRuleDialog:
- Collection fields initialise to [] so first-render loops are empty.
- Single-object data fields become nullable; the razor wraps
  consumption in `@if (field is null) { <p>Lädt…</p> return; }`.
- <PlotlyChart> guarded behind a null check on config/layout/data so
  Plotly.Blazor's @bind doesn't see nulls.
- Header/footer strings initialise to "" instead of null!.

Architectural hygiene on a single-user local Blazor Server app: the
observed win is one File.ReadAllBytesAsync and one
File.WriteAllBytesAsync per Load/Save, and after PR D each file is
loaded at most once per SignalR circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 18:30:44 +02:00

153 lines
5.6 KiB
C#

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<string, SemaphoreSlim> 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;
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
/// <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="SaveAsync"/> invalidates the entry.
/// </summary>
protected Task<List<T>> LoadListAsync<T>(string fileName)
{
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
return cache.GetOrLoadAsync(filePath, async () => await LoadAsync<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 async Task SaveAsync<T>(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);
}
/// <summary>
/// Load an object from an xml file (uncached; used for one-off reads such as file
/// existence bootstrapping). Prefer <see cref="LoadListAsync{T}"/> for repository reads.
/// </summary>
protected async Task<T> LoadAsync<T>(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));
}
}