PR B: atomic XML writes and per-file locking

Save<T> writes to a .tmp sibling and atomically renames, so a crash
mid-write leaves the previous file intact. Load<T>, Save<T>, and
EnsureFileExisits share a per-absolute-path lock so concurrent
Save+Save and Save+Load can't observe a half-written file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-07-02 20:40:55 +02:00
parent 9f11f6490b
commit 152d4c3d57

View file

@ -1,4 +1,6 @@
using System.IO; using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text; using System.Text;
using System.Xml; using System.Xml;
using System.Xml.Serialization; using System.Xml.Serialization;
@ -8,6 +10,11 @@ namespace Schaad.Accounting.Repositories
{ {
public abstract class BaseRepository 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 ISettingsService settingsService;
protected BaseRepository(ISettingsService settingsService) protected BaseRepository(ISettingsService settingsService)
@ -18,6 +25,8 @@ namespace Schaad.Accounting.Repositories
protected void EnsureFileExisits(string fileName) protected void EnsureFileExisits(string fileName)
{ {
string filePath = Path.Combine(settingsService.GetDbPath(), fileName); string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
lock (GetLock(filePath))
{
if (File.Exists(filePath) == false) if (File.Exists(filePath) == false)
{ {
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName); var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
@ -27,26 +36,42 @@ namespace Schaad.Accounting.Repositories
} }
} }
} }
}
/// <summary> /// <summary>
/// Save an object to an xml file /// 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.
/// </summary> /// </summary>
protected void Save<T>(T obj, string fileName) protected void Save<T>(T obj, string fileName)
{ {
var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
using (var sww = new MemoryStream()) var tmpPath = filePath + ".tmp";
lock (GetLock(filePath))
{ {
var settings = new XmlWriterSettings var settings = new XmlWriterSettings
{ {
Encoding = Encoding.UTF8, Encoding = Encoding.UTF8,
Indent = true Indent = true
}; };
using (var writer = XmlWriter.Create(sww, settings))
try
{ {
var xsSubmit = new XmlSerializer(typeof(T)); using (var writer = XmlWriter.Create(tmpPath, settings))
xsSubmit.Serialize(writer, obj); {
var xml = Encoding.UTF8.GetString(sww.ToArray()); var serializer = new XmlSerializer(typeof(T));
File.WriteAllText(filePath, xml); serializer.Serialize(writer, obj);
}
File.Move(tmpPath, filePath, overwrite: true);
}
catch
{
if (File.Exists(tmpPath))
{
try { File.Delete(tmpPath); } catch { /* best effort */ }
}
throw;
} }
} }
} }
@ -57,6 +82,9 @@ namespace Schaad.Accounting.Repositories
protected T Load<T>(string fileName) protected T Load<T>(string fileName)
{ {
var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
lock (GetLock(filePath))
{
if (File.Exists(filePath) == false) if (File.Exists(filePath) == false)
{ {
return default(T); return default(T);
@ -69,4 +97,8 @@ namespace Schaad.Accounting.Repositories
} }
} }
} }
private static object GetLock(string filePath)
=> FileLocks.GetOrAdd(filePath, _ => new object());
}
} }