using NATS.Server.Raft; // Go reference: server/raft.go (WAL binary format, compaction, CRC integrity) namespace NATS.Server.Tests.Raft; public class RaftWalTests : IDisposable { private readonly string _root; public RaftWalTests() { _root = Path.Combine(Path.GetTempPath(), $"nats-wal-{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); } public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } // Go reference: server/raft.go WAL append + recover [Fact] public async Task Wal_persists_and_recovers_entries() { var walPath = Path.Combine(_root, "raft.wal"); // Write entries { using var wal = new RaftWal(walPath); await wal.AppendAsync(new RaftLogEntry(1, 1, "cmd-1")); await wal.AppendAsync(new RaftLogEntry(2, 1, "cmd-2")); await wal.AppendAsync(new RaftLogEntry(3, 2, "cmd-3")); await wal.SyncAsync(); } // Recover using var recovered = RaftWal.Load(walPath); var entries = recovered.Entries; entries.Count.ShouldBe(3); entries[0].Index.ShouldBe(1); entries[0].Term.ShouldBe(1); entries[0].Command.ShouldBe("cmd-1"); entries[2].Index.ShouldBe(3); entries[2].Term.ShouldBe(2); } // Go reference: server/raft.go compactLog [Fact] public async Task Wal_compact_removes_old_entries() { var walPath = Path.Combine(_root, "compact.wal"); using var wal = new RaftWal(walPath); for (int i = 1; i <= 10; i++) await wal.AppendAsync(new RaftLogEntry(i, 1, $"cmd-{i}")); await wal.SyncAsync(); await wal.CompactAsync(5); // remove entries 1-5 using var recovered = RaftWal.Load(walPath); recovered.Entries.Count.ShouldBe(5); recovered.Entries.First().Index.ShouldBe(6); } // Go reference: server/raft.go WAL crash-truncation tolerance [Fact] public async Task Wal_handles_truncated_file() { var walPath = Path.Combine(_root, "truncated.wal"); { using var wal = new RaftWal(walPath); await wal.AppendAsync(new RaftLogEntry(1, 1, "good-entry")); await wal.AppendAsync(new RaftLogEntry(2, 1, "will-be-truncated")); await wal.SyncAsync(); } // Truncate last few bytes to simulate crash using (var fs = File.OpenWrite(walPath)) fs.SetLength(fs.Length - 3); using var recovered = RaftWal.Load(walPath); recovered.Entries.Count.ShouldBe(1); recovered.Entries.First().Command.ShouldBe("good-entry"); } // Go reference: server/raft.go storeMeta (term + votedFor persistence) [Fact] public async Task RaftNode_persists_term_and_vote() { var dir = Path.Combine(_root, "node-persist"); Directory.CreateDirectory(dir); { using var node = new RaftNode("n1", persistDirectory: dir); node.TermState.CurrentTerm = 5; node.TermState.VotedFor = "n2"; await node.PersistAsync(default); } using var recovered = new RaftNode("n1", persistDirectory: dir); await recovered.LoadPersistedStateAsync(default); recovered.Term.ShouldBe(5); recovered.TermState.VotedFor.ShouldBe("n2"); } // Go reference: server/raft.go WAL empty file edge case [Fact] public async Task Wal_empty_file_loads_no_entries() { var walPath = Path.Combine(_root, "empty.wal"); { using var wal = new RaftWal(walPath); await wal.SyncAsync(); } using var recovered = RaftWal.Load(walPath); recovered.Entries.Count.ShouldBe(0); } // Go reference: server/raft.go WAL CRC integrity check [Fact] public async Task Wal_crc_validates_record_integrity() { var walPath = Path.Combine(_root, "crc.wal"); { using var wal = new RaftWal(walPath); await wal.AppendAsync(new RaftLogEntry(1, 1, "valid")); await wal.AppendAsync(new RaftLogEntry(2, 1, "also-valid")); await wal.SyncAsync(); } // Corrupt one byte in the tail of the file (inside the second record) var bytes = File.ReadAllBytes(walPath); bytes[^5] ^= 0xFF; File.WriteAllBytes(walPath, bytes); // Load should recover exactly the first record, stopping at the corrupt second using var recovered = RaftWal.Load(walPath); recovered.Entries.Count.ShouldBe(1); recovered.Entries.First().Command.ShouldBe("valid"); } }