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>
This commit is contained in:
Claudio Schaad 2026-07-02 20:52:24 +02:00
parent 8961bb234c
commit 1c7ba7f416
10 changed files with 82 additions and 48 deletions

View file

@ -11,20 +11,16 @@ namespace Schaad.Accounting.Repositories
{
private readonly string ACCOUNTS = "Accounts.xml";
public AccountRepository(ISettingsService settingsService) : base(settingsService)
public AccountRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureAccountsFile();
}
/// <summary>
/// Load accounts
/// </summary>
public List<Account> GetAccountList()
{
var accounts = Load<List<Account>>(ACCOUNTS);
return accounts ?? new List<Account>();
}
public List<Account> GetAccountList() => LoadList<Account>(ACCOUNTS);
/// <summary>
/// Save an account (insert/update)

View file

@ -10,18 +10,15 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
public BankTransactionRepository(ISettingsService settingsService) : base(settingsService)
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
}
/// <summary>
/// Load bank transactions
/// </summary>
public List<BankTransaction> GetBankTransactionList()
{
var transactions = Load<List<BankTransaction>>(BANK_TRANSACTIONS);
return transactions != null ? transactions : new List<BankTransaction>();
}
public List<BankTransaction> GetBankTransactionList() => LoadList<BankTransaction>(BANK_TRANSACTIONS);
/// <summary>
/// Save new bank transactions, ignore existing ones (no update)

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
@ -16,10 +17,12 @@ namespace Schaad.Accounting.Repositories
new(StringComparer.OrdinalIgnoreCase);
protected readonly ISettingsService settingsService;
protected readonly RepositoryCache cache;
protected BaseRepository(ISettingsService settingsService)
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache)
{
this.settingsService = settingsService;
this.cache = cache;
}
protected void EnsureFileExists(string fileName)
@ -38,9 +41,20 @@ namespace Schaad.Accounting.Repositories
}
}
/// <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.
/// 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)
{
@ -74,10 +88,13 @@ namespace Schaad.Accounting.Repositories
throw;
}
}
cache.Invalidate(filePath);
}
/// <summary>
/// Load an object from an xml file
/// 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)
{

View file

@ -10,7 +10,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BOOKING_RULES = "BookingRules.xml";
public BookingRuleRepository(ISettingsService settingsService) : base(settingsService)
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(BOOKING_RULES);
}
@ -18,11 +19,7 @@ namespace Schaad.Accounting.Repositories
/// <summary>
/// Load booking rules
/// </summary>
public List<BookingRule> GetBookingRuleList()
{
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
return bookingTexts ?? new List<BookingRule>();
}
public List<BookingRule> GetBookingRuleList() => LoadList<BookingRule>(BOOKING_RULES);
/// <summary>
/// Save a booking rule (insert/update)

View file

@ -10,19 +10,16 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
public BookingTextRepository(ISettingsService settingsService) : base(settingsService)
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(BOOKING_TEXTS);
}
/// <summary>
/// Load bokking texts
/// Load booking texts
/// </summary>
public List<BookingText> GetBookingTextList()
{
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
return bookingTexts ?? new List<BookingText>();
}
public List<BookingText> GetBookingTextList() => LoadList<BookingText>(BOOKING_TEXTS);
/// <summary>
/// Save a booking text (insert/update)

View file

@ -10,7 +10,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitionRepository(ISettingsService settingsService) : base(settingsService)
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(SPLIT_PREDEFINITION);
}
@ -18,11 +19,7 @@ namespace Schaad.Accounting.Repositories
/// <summary>
/// Load split predefinitions
/// </summary>
public List<SplitPredefinition> GetSplitPredefinitionList()
{
var definitions = Load<List<SplitPredefinition>>(SPLIT_PREDEFINITION);
return definitions ?? new List<SplitPredefinition>();
}
public List<SplitPredefinition> GetSplitPredefinitionList() => LoadList<SplitPredefinition>(SPLIT_PREDEFINITION);
/// <summary>
/// Save a split predefinition (insert/update)

View file

@ -11,7 +11,8 @@ namespace Schaad.Accounting.Repositories
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
private readonly string SUBCLASSES = "SubClasses.xml";
public SubclassRepository(ISettingsService settingsService) : base(settingsService)
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(SUBCLASSES);
@ -22,13 +23,9 @@ namespace Schaad.Accounting.Repositories
}
/// <summary>
/// Load bokking texts
/// Load subclasses
/// </summary>
public List<SubClass> GetSubClassList()
{
var subclasses = Load<List<SubClass>>(SUBCLASSES);
return subclasses ?? new List<SubClass>();
}
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
/// <summary>
/// Save a booking text (insert/update)

View file

@ -11,7 +11,8 @@ namespace Schaad.Accounting.Repositories
private readonly IAccountRepository accountRepository;
private readonly string TRANSACTIONS = "Transactions.xml";
public TransactionRepository(ISettingsService settingsService, IAccountRepository accountRepository) : base(settingsService)
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository)
: base(settingsService, cache)
{
this.accountRepository = accountRepository;
}
@ -19,11 +20,7 @@ namespace Schaad.Accounting.Repositories
/// <summary>
/// Get transaction list
/// </summary>
public List<Transaction> GetTransactionList()
{
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
return transactionList ?? new List<Transaction>();
}
public List<Transaction> GetTransactionList() => LoadList<Transaction>(TRANSACTIONS);
/// <summary>
/// Save a transaction (insert/update)

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
namespace Schaad.Accounting.Repositories
{
/// <summary>
/// Per-scope cache of deserialized XML lists, keyed by absolute file path.
///
/// Registered as Scoped so it lives for the SignalR circuit; each XML file is
/// then loaded at most once per circuit. Writes go through <see cref="BaseRepository.Save"/>,
/// which invalidates the corresponding entry so the next read reloads from disk.
///
/// Not thread-safe — callers within a single Blazor Server circuit run serialized on the
/// circuit's synchronization context, so no locking is needed.
/// </summary>
public class RepositoryCache
{
private readonly Dictionary<string, object> entries =
new(StringComparer.OrdinalIgnoreCase);
public List<T> GetOrLoad<T>(string cacheKey, Func<List<T>> loader)
{
if (entries.TryGetValue(cacheKey, out var cached))
{
return (List<T>)cached;
}
var list = loader();
entries[cacheKey] = list;
return list;
}
public void Invalidate(string cacheKey)
{
entries.Remove(cacheKey);
}
}
}

View file

@ -16,6 +16,7 @@ namespace Schaad.Accounting.UI
// can safely become Scoped (see IMPROVEMENT_PLAN.md).
services.AddSingleton<ISettingsService, SettingsService>();
services.AddScoped<RepositoryCache>();
services.AddScoped<IAccountRepository, AccountRepository>();
services.AddScoped<IBankTransactionRepository, BankTransactionRepository>();
services.AddScoped<IBookingRuleRepository, BookingRuleRepository>();