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>
153 lines
5.6 KiB
C#
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));
|
|
}
|
|
}
|