Konfig- und Zustandsdateien atomar schreiben
File.WriteAllText kuerzt die Zieldatei zuerst auf null und fuellt sie dann. Bricht der Vorgang dazwischen ab, ist der alte Inhalt weg und der neue unvollstaendig. Das ist im Betrieb bereits eingetreten: In der Instanz TradingTeam lag eine TokenUsage.json.corrupt_..., die die Fehlerbehandlung beiseitegelegt hatte. Der Verbrauch bis dahin war verloren. AtomicFile schreibt in eine Nebendatei, erzwingt das Schreiben auf die Platte und ersetzt dann. Umgestellt sind ChatHistory, ChatContext, alle Instanz- und Agentenkonfigurationen, Identity und Soul, die App-Einstellungen sowie der Stock-Index. Zum Ersetzen wurde das Windows-Verhalten gemessen statt vermutet. Mit einem Leser, der die Zieldatei geoeffnet haelt: Freigabe des Lesers File.Move File.Replace Read scheitert scheitert ReadWrite scheitert scheitert ReadWrite | Delete scheitert funktioniert File.Move verlangt die Zieldatei exklusiv und scheitert deshalb immer, sobald jemand sie geoeffnet hat. Daher File.Replace — und ein Lesehelfer AtomicFile.ReadAllText, der das Loeschen freigibt, damit unsere eigenen Leser keinen Schreiber blockieren. Die Leser in InstanceDirectoryManager und beim Laden der Chatverlaeufe nutzen ihn jetzt. Zusaetzlich ein Schloss je Zieldatei: Zwei gleichzeitige Schreibvorgaenge auf dieselbe Datei sind ohnehin ein Rennen, ohne Serialisierung scheitern sie aber zusaetzlich mit "Zugriff verweigert". Fuer fremde Leser wie Virenscanner bleibt eine Wiederholung mit Wartezeit. 366 Tests gruen (218 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ef3e519f6c
commit
1157d28588
@@ -9,6 +9,7 @@ using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Engine;
|
||||
@@ -613,7 +614,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
if (File.Exists(historyPath))
|
||||
{
|
||||
var history = JsonSerializer.Deserialize<List<ChatEntry>>(
|
||||
File.ReadAllText(historyPath), _jsonOpts);
|
||||
AtomicFile.ReadAllText(historyPath), _jsonOpts);
|
||||
if (history is { Count: > 0 })
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -624,7 +625,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
var contextPath = Path.Combine(dir, "ChatContext.json");
|
||||
if (File.Exists(contextPath))
|
||||
{
|
||||
var raw = File.ReadAllText(contextPath);
|
||||
var raw = AtomicFile.ReadAllText(contextPath);
|
||||
List<ChatMessage>? context = null;
|
||||
|
||||
// Versuche zuerst als Array (direktes List<ChatMessage>)
|
||||
@@ -779,13 +780,15 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
_chatContexts.TryGetValue(agentId, out context);
|
||||
}
|
||||
|
||||
// Atomar schreiben: Ein Absturz mitten im Vorgang würde sonst den
|
||||
// bisherigen Verlauf löschen und einen halben zurücklassen.
|
||||
if (history is not null)
|
||||
File.WriteAllText(
|
||||
AtomicFile.WriteAllText(
|
||||
Path.Combine(dir, "ChatHistory.json"),
|
||||
JsonSerializer.Serialize(history, _jsonOpts));
|
||||
|
||||
if (context is not null)
|
||||
File.WriteAllText(
|
||||
AtomicFile.WriteAllText(
|
||||
Path.Combine(dir, "ChatContext.json"),
|
||||
JsonSerializer.Serialize(context, _jsonOpts));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Dateien so, dass ein Absturz keine halbe Datei hinterlässt.
|
||||
///
|
||||
/// Hintergrund: Alle Konfigurations- und Zustandsdateien wurden mit
|
||||
/// <c>File.WriteAllText</c> geschrieben. Das kürzt die Zieldatei zuerst auf null und
|
||||
/// füllt sie dann — bricht der Vorgang dazwischen ab, ist der alte Inhalt weg und der
|
||||
/// neue unvollständig.
|
||||
///
|
||||
/// Das ist bereits eingetreten: In einer Instanz lag eine
|
||||
/// <c>TokenUsage.json.corrupt_…</c>, die die Fehlerbehandlung beiseitegelegt hatte.
|
||||
///
|
||||
/// Ablauf hier: in eine Nebendatei schreiben, auf die Platte zwingen, dann durch
|
||||
/// Umbenennen ersetzen. Das Ersetzen ist auf NTFS atomar — es gibt keinen Zeitpunkt,
|
||||
/// zu dem die Zieldatei halb beschrieben wäre.
|
||||
/// </summary>
|
||||
public static class AtomicFile
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
/// <summary>
|
||||
/// Ein Schloss je Zieldatei.
|
||||
///
|
||||
/// Zwei gleichzeitige Schreibvorgänge auf dieselbe Datei sind ohnehin ein Rennen —
|
||||
/// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
|
||||
/// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
|
||||
/// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();
|
||||
|
||||
private static SemaphoreSlim LockFor(string fullPath)
|
||||
=> PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
|
||||
///
|
||||
/// <c>File.ReadAllText</c> öffnet ohne Freigabe zum Löschen — solange der Lesevorgang
|
||||
/// läuft, lässt Windows die Datei nicht ersetzen. Ein Leser könnte damit einen
|
||||
/// Schreiber scheitern lassen.
|
||||
///
|
||||
/// Wird die Datei während des Lesens ersetzt, liefert der bereits geöffnete Griff
|
||||
/// weiterhin den alten Inhalt — vollständig und in sich stimmig. Genau das ist
|
||||
/// gewünscht: nie ein halber Stand.
|
||||
/// </summary>
|
||||
public static string ReadAllText(string path, Encoding? encoding = null)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
using var reader = new StreamReader(stream, encoding ?? Utf8NoBom, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
public static void WriteAllText(string path, string content, Encoding? encoding = null)
|
||||
=> WriteAllBytes(path, (encoding ?? Utf8NoBom).GetBytes(content));
|
||||
|
||||
public static async Task WriteAllTextAsync(
|
||||
string path, string content, Encoding? encoding = null, CancellationToken ct = default)
|
||||
=> await WriteAllBytesAsync(path, (encoding ?? Utf8NoBom).GetBytes(content), ct);
|
||||
|
||||
public static void WriteAllBytes(string path, byte[] bytes)
|
||||
{
|
||||
var (fullPath, tempPath) = PreparePaths(path);
|
||||
var gate = LockFor(fullPath);
|
||||
|
||||
gate.Wait();
|
||||
try
|
||||
{
|
||||
using (var stream = CreateTempStream(tempPath))
|
||||
{
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
// Ohne dieses Flush lägen die Daten nur im Schreibcache des
|
||||
// Betriebssystems — bei Stromausfall wäre die Umbenennung erfolgt,
|
||||
// der Inhalt aber nicht auf der Platte.
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
Commit(tempPath, fullPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken ct = default)
|
||||
{
|
||||
var (fullPath, tempPath) = PreparePaths(path);
|
||||
var gate = LockFor(fullPath);
|
||||
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await using (var stream = CreateTempStream(tempPath))
|
||||
{
|
||||
await stream.WriteAsync(bytes, ct);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
Commit(tempPath, fullPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bausteine ───
|
||||
|
||||
private static (string FullPath, string TempPath) PreparePaths(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Eindeutiger Name, damit parallele Schreibvorgänge sich nicht die
|
||||
// Nebendatei streitig machen.
|
||||
var tempPath = fullPath + ".tmp_" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
return (fullPath, tempPath);
|
||||
}
|
||||
|
||||
private static FileStream CreateTempStream(string tempPath)
|
||||
=> new(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
|
||||
/// <summary>Versuche für das Ersetzen — siehe <see cref="Commit"/>.</summary>
|
||||
private const int CommitAttempts = 20;
|
||||
|
||||
/// <summary>Obergrenze der Wartezeit zwischen zwei Versuchen.</summary>
|
||||
private static readonly TimeSpan MaxCommitBackoff = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt die Zieldatei durch die Nebendatei.
|
||||
///
|
||||
/// Bewusst <c>File.Replace</c> und nicht <c>File.Move(overwrite: true)</c>. Gemessen
|
||||
/// auf Windows, mit einem Leser, der die Zieldatei geöffnet hält:
|
||||
///
|
||||
/// <code>
|
||||
/// Freigabe des Lesers File.Move File.Replace
|
||||
/// Read scheitert scheitert
|
||||
/// ReadWrite scheitert scheitert
|
||||
/// ReadWrite | Delete scheitert funktioniert
|
||||
/// </code>
|
||||
///
|
||||
/// <c>File.Move</c> verlangt die Zieldatei exklusiv und scheitert deshalb immer,
|
||||
/// sobald jemand sie geöffnet hat. <c>File.Replace</c> kommt damit zurecht, sofern
|
||||
/// der Leser das Löschen freigibt — dafür gibt es <see cref="ReadAllText"/>.
|
||||
///
|
||||
/// Wiederholt wird trotzdem: Fremde Leser wie Virenscanner oder Sicherungsläufe
|
||||
/// öffnen ohne diese Freigabe. Solche Sperren sind kurzlebig.
|
||||
/// </summary>
|
||||
private static void Commit(string tempPath, string fullPath)
|
||||
{
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
// File.Replace verlangt eine vorhandene Zieldatei.
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
File.Replace(tempPath, fullPath,
|
||||
destinationBackupFileName: null, ignoreMetadataErrors: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, fullPath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
&& attempt < CommitAttempts)
|
||||
{
|
||||
var wait = Math.Min(attempt * 20, (int)MaxCommitBackoff.TotalMilliseconds);
|
||||
Thread.Sleep(wait);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch { /* Aufräumen darf den eigentlichen Fehler nicht verdecken */ }
|
||||
}
|
||||
}
|
||||
@@ -548,7 +548,10 @@ public sealed class FileRWTool : IAgentTool
|
||||
}
|
||||
|
||||
index.Add(indexEntry);
|
||||
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(index, StockJsonOpts), new UTF8Encoding(false), ct);
|
||||
// Atomar: Der Index ist die einzige Übersicht über die Datenpunkte —
|
||||
// eine halb geschriebene Datei wäre nicht wiederherstellbar.
|
||||
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(
|
||||
indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user