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:
@@ -0,0 +1,149 @@
|
||||
// Go ref: filestore.go:10599 (_writeFullState)
|
||||
// AtomicFileWriter wraps the write-to-temp-then-rename pattern used by
|
||||
// Go's fileStore._writeFullState to guarantee crash-safe state persistence.
|
||||
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream.Storage;
|
||||
|
||||
public sealed class AtomicFileWriterTests : IDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _dir;
|
||||
|
||||
public AtomicFileWriterTests()
|
||||
{
|
||||
_dir = Directory.CreateTempSubdirectory("atomic_writer_tests_");
|
||||
}
|
||||
|
||||
public void Dispose() => _dir.Delete(recursive: true);
|
||||
|
||||
private string TempPath(string name) => Path.Combine(_dir.FullName, name);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// byte[] overload
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_creates_file()
|
||||
{
|
||||
var path = TempPath("state.json");
|
||||
var data = "{ \"seq\": 1 }"u8.ToArray();
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, data);
|
||||
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
var written = await File.ReadAllBytesAsync(path);
|
||||
written.ShouldBe(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_no_temp_file_remains()
|
||||
{
|
||||
var path = TempPath("state.json");
|
||||
var data = "hello world"u8.ToArray();
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, data);
|
||||
|
||||
// No .tmp file should remain after a successful write.
|
||||
// The temp file uses a random component ({path}.{random}.tmp) so check by extension.
|
||||
_dir.EnumerateFiles("*.tmp").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_overwrites_existing()
|
||||
{
|
||||
var path = TempPath("state.json");
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, "first"u8.ToArray());
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, "second"u8.ToArray());
|
||||
|
||||
var written = await File.ReadAllTextAsync(path);
|
||||
written.ShouldBe("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_concurrent_writes_are_safe()
|
||||
{
|
||||
// Multiple concurrent writes to the same file must not corrupt it.
|
||||
// Each write uses a unique payload; after all writes complete the
|
||||
// file must contain exactly one of the payloads (no partial data).
|
||||
var path = TempPath("concurrent.json");
|
||||
const int concurrency = 20;
|
||||
|
||||
var tasks = Enumerable.Range(0, concurrency).Select(i =>
|
||||
AtomicFileWriter.WriteAtomicallyAsync(path, System.Text.Encoding.UTF8.GetBytes($"payload-{i:D3}")));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// File must exist and contain exactly one complete payload.
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
content.ShouldMatch(@"^payload-\d{3}$");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadOnlyMemory<byte> overload
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_memory_overload_creates_file()
|
||||
{
|
||||
var path = TempPath("state_mem.json");
|
||||
ReadOnlyMemory<byte> data = "{ \"seq\": 42 }"u8.ToArray();
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, data);
|
||||
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
var written = await File.ReadAllBytesAsync(path);
|
||||
written.ShouldBe(data.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_memory_overload_no_temp_file_remains()
|
||||
{
|
||||
var path = TempPath("state_mem.json");
|
||||
ReadOnlyMemory<byte> data = "memory data"u8.ToArray();
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, data);
|
||||
|
||||
// The temp file uses a random component ({path}.{random}.tmp) so check by extension.
|
||||
_dir.EnumerateFiles("*.tmp").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_memory_overload_overwrites_existing()
|
||||
{
|
||||
var path = TempPath("state_mem.json");
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, (ReadOnlyMemory<byte>)"first"u8.ToArray());
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, (ReadOnlyMemory<byte>)"second"u8.ToArray());
|
||||
|
||||
var written = await File.ReadAllTextAsync(path);
|
||||
written.ShouldBe("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_writes_empty_data()
|
||||
{
|
||||
var path = TempPath("empty.json");
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, Array.Empty<byte>());
|
||||
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
var written = await File.ReadAllBytesAsync(path);
|
||||
written.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAtomicallyAsync_writes_large_payload()
|
||||
{
|
||||
var path = TempPath("large.bin");
|
||||
var data = new byte[256 * 1024]; // 256 KB
|
||||
Random.Shared.NextBytes(data);
|
||||
|
||||
await AtomicFileWriter.WriteAtomicallyAsync(path, data);
|
||||
|
||||
var written = await File.ReadAllBytesAsync(path);
|
||||
written.ShouldBe(data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user