feat: add atomic file writer with SemaphoreSlim for crash-safe state writes (Gap 1.6)

- Add AtomicFileWriter static helper: writes to {path}.{random}.tmp, flushes,
  then File.Move(overwrite:true) — concurrent-safe via unique temp path per call
- Add _stateWriteLock (SemaphoreSlim 1,1) to FileStore; dispose in both Dispose
  and DisposeAsync paths
- Promote WriteStreamState to async WriteStreamStateAsync using AtomicFileWriter
  under the write lock; FlushAllPending now returns Task
- Update IStreamStore.FlushAllPending signature to Task; fix MemStore no-op impl
- Fix FileStoreCrashRecoveryTests to await FlushAllPending (3 sync→async tests)
- Add 9 AtomicFileWriterTests covering create, no-tmp-remains, overwrite,
  concurrent safety, memory overload, empty data, and large payload
This commit is contained in:
Joseph Doherty
2026-02-25 07:55:33 -05:00
parent 5beeb1b3f6
commit 646a5eb2ae
6 changed files with 238 additions and 23 deletions

View File

@@ -54,7 +54,7 @@ public sealed class FileStoreCrashRecoveryTests : IDisposable
// Go: TestFileStoreSyncIntervals (filestore_test.go) — verifies that pending writes
// are flushed to disk and the .blk file is non-empty after FlushAllPending.
[Fact]
public void FlushAllPending_flushes_active_block()
public async Task FlushAllPending_flushes_active_block()
{
// Arrange: write a message to the store.
const string sub = "flush-block";
@@ -64,7 +64,7 @@ public sealed class FileStoreCrashRecoveryTests : IDisposable
store.StoreMsg("events.a", null, "payload-for-flush"u8.ToArray(), 0L);
// Act: flush all pending writes.
store.FlushAllPending();
await store.FlushAllPending();
// Assert: at least one .blk file exists and it is non-empty, proving the
// active block was flushed to disk.
@@ -81,7 +81,7 @@ public sealed class FileStoreCrashRecoveryTests : IDisposable
// Go: TestFileStoreWriteFullStateBasics (filestore_test.go:5461) — verifies that
// WriteStreamState creates a valid, atomic stream.state checkpoint file.
[Fact]
public void FlushAllPending_writes_stream_state_file()
public async Task FlushAllPending_writes_stream_state_file()
{
// Arrange: store several messages across subjects.
const string sub = "state-file";
@@ -93,13 +93,13 @@ public sealed class FileStoreCrashRecoveryTests : IDisposable
store.StoreMsg("events.a", null, "event-1"u8.ToArray(), 0L);
// Act: flush — this should write stream.state atomically.
store.FlushAllPending();
await store.FlushAllPending();
// Assert: stream.state exists and no leftover .tmp file.
// AtomicFileWriter uses {path}.{random}.tmp so check by extension, not exact name.
var statePath = Path.Combine(dir, "stream.state");
var tmpPath = statePath + ".tmp";
File.Exists(statePath).ShouldBeTrue("stream.state checkpoint must exist after FlushAllPending");
File.Exists(tmpPath).ShouldBeFalse("stream.state.tmp must be renamed away after atomic write");
Directory.GetFiles(dir, "*.tmp").ShouldBeEmpty("all .tmp staging files must be renamed away after atomic write");
// Assert: the file is valid JSON with the expected fields.
var json = File.ReadAllText(statePath);
@@ -118,17 +118,17 @@ public sealed class FileStoreCrashRecoveryTests : IDisposable
// Go: FlushAllPending is idempotent — calling it twice must not throw and must
// overwrite the previous state file with the latest state.
[Fact]
public void FlushAllPending_is_idempotent()
public async Task FlushAllPending_is_idempotent()
{
const string sub = "flush-idempotent";
var dir = StoreDir(sub);
using var store = CreateStore(sub);
store.StoreMsg("foo", null, "msg-1"u8.ToArray(), 0L);
store.FlushAllPending();
await store.FlushAllPending();
store.StoreMsg("foo", null, "msg-2"u8.ToArray(), 0L);
store.FlushAllPending();
await store.FlushAllPending();
// The state file should reflect the second flush (2 messages, seq 1..2).
var statePath = Path.Combine(dir, "stream.state");
@@ -197,7 +197,7 @@ public sealed class FileStoreCrashRecoveryTests : IDisposable
store.StoreMsg("events", null, Encoding.UTF8.GetBytes($"msg-{i}"), 0L);
// Flush to ensure data is on disk before we close.
store.FlushAllPending();
await store.FlushAllPending();
}
// Simulate a crash mid-write by truncating the .blk file by a few bytes at