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

@@ -47,6 +47,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
// Reference: golang/nats-server/server/filestore.go:290 (fss/ttl fields).
private HashWheel? _ttlWheel;
// Mutual-exclusion lock for state file writes. Ensures that concurrent
// FlushAllPending calls (e.g. from a flush timer and a shutdown path) do not
// race on the stream.state / stream.state.tmp files.
// Reference: golang/nats-server/server/filestore.go — fsMsgBlock.mu (write lock).
private readonly SemaphoreSlim _stateWriteLock = new(1, 1);
public int BlockCount => _blocks.Count;
public bool UsedIndexManifestOnStartup { get; private set; }
@@ -662,16 +668,18 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
public ValueTask DisposeAsync()
{
DisposeAllBlocks();
_stateWriteLock.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>
/// Synchronous dispose — releases all block file handles.
/// Synchronous dispose — releases all block file handles and the state-write semaphore.
/// Allows the store to be used in synchronous test contexts with <c>using</c> blocks.
/// </summary>
public void Dispose()
{
DisposeAllBlocks();
_stateWriteLock.Dispose();
}
/// <summary>
@@ -1811,23 +1819,24 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
/// good sequence without re-scanning every block.
/// Reference: golang/nats-server/server/filestore.go:5783 (flushPendingWritesUnlocked).
/// </summary>
public void FlushAllPending()
public async Task FlushAllPending()
{
_activeBlock?.Flush();
WriteStreamState();
await WriteStreamStateAsync();
}
/// <summary>
/// Atomically persists a compact stream state snapshot to disk using the
/// write-to-temp-then-rename pattern so that a partial write never leaves
/// a corrupt state file.
/// Atomically persists a compact stream state snapshot to disk using
/// <see cref="AtomicFileWriter"/> (write-to-temp-then-rename) so that a
/// partial write never leaves a corrupt state file. The <see cref="_stateWriteLock"/>
/// semaphore serialises concurrent flush calls so that only one write is
/// in-flight at a time.
/// The file is written as JSON to <c>{Directory}/stream.state</c>.
/// Reference: golang/nats-server/server/filestore.go:5820 (writeFullState).
/// </summary>
private void WriteStreamState()
private async Task WriteStreamStateAsync()
{
var statePath = Path.Combine(_options.Directory, "stream.state");
var tmpPath = statePath + ".tmp";
var snapshot = new StreamStateSnapshot
{
@@ -1837,9 +1846,17 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
Bytes = (ulong)_blocks.Sum(b => b.BytesUsed),
};
var json = JsonSerializer.Serialize(snapshot);
File.WriteAllText(tmpPath, json);
File.Move(tmpPath, statePath, overwrite: true);
var json = JsonSerializer.SerializeToUtf8Bytes(snapshot);
await _stateWriteLock.WaitAsync();
try
{
await AtomicFileWriter.WriteAtomicallyAsync(statePath, json);
}
finally
{
_stateWriteLock.Release();
}
}
// -------------------------------------------------------------------------