using NATS.Server.Raft; namespace NATS.Server.Raft.Tests.Raft; /// /// Tests for B5: Snapshot Checkpoints and Log Compaction. /// Go reference: raft.go:3200-3400 (CreateSnapshotCheckpoint), raft.go:3500-3700 (installSnapshot). /// public class RaftSnapshotCheckpointTests { // -- Helpers -- private static (RaftNode[] nodes, InMemoryRaftTransport transport) CreateCluster(int size) { var transport = new InMemoryRaftTransport(); var nodes = Enumerable.Range(1, size) .Select(i => new RaftNode($"n{i}", transport)) .ToArray(); foreach (var node in nodes) { transport.Register(node); node.ConfigureCluster(nodes); } return (nodes, transport); } private static RaftNode ElectLeader(RaftNode[] nodes) { var candidate = nodes[0]; candidate.StartElection(nodes.Length); foreach (var voter in nodes.Skip(1)) candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), nodes.Length); return candidate; } // -- RaftSnapshotCheckpoint type tests -- [Fact] public void Checkpoint_creation_with_data() { var checkpoint = new RaftSnapshotCheckpoint { SnapshotIndex = 10, SnapshotTerm = 2, Data = [1, 2, 3, 4, 5], }; checkpoint.SnapshotIndex.ShouldBe(10); checkpoint.SnapshotTerm.ShouldBe(2); checkpoint.Data.Length.ShouldBe(5); checkpoint.IsComplete.ShouldBeFalse(); } [Fact] public void Chunk_assembly_single_chunk() { var checkpoint = new RaftSnapshotCheckpoint { SnapshotIndex = 5, SnapshotTerm = 1, }; checkpoint.AddChunk([10, 20, 30]); var result = checkpoint.Assemble(); result.Length.ShouldBe(3); result[0].ShouldBe((byte)10); result[1].ShouldBe((byte)20); result[2].ShouldBe((byte)30); checkpoint.IsComplete.ShouldBeTrue(); } [Fact] public void Chunk_assembly_multiple_chunks() { var checkpoint = new RaftSnapshotCheckpoint { SnapshotIndex = 5, SnapshotTerm = 1, }; checkpoint.AddChunk([1, 2]); checkpoint.AddChunk([3, 4, 5]); checkpoint.AddChunk([6]); var result = checkpoint.Assemble(); result.Length.ShouldBe(6); result.ShouldBe(new byte[] { 1, 2, 3, 4, 5, 6 }); checkpoint.IsComplete.ShouldBeTrue(); } [Fact] public void Chunk_assembly_empty_returns_data() { // When no chunks added, Assemble returns the initial Data property var checkpoint = new RaftSnapshotCheckpoint { SnapshotIndex = 5, SnapshotTerm = 1, Data = [99, 100], }; var result = checkpoint.Assemble(); result.ShouldBe(new byte[] { 99, 100 }); checkpoint.IsComplete.ShouldBeFalse(); // no chunks to assemble } // -- RaftLog.Compact tests -- [Fact] public void CompactLog_removes_old_entries() { // Go reference: raft.go WAL compact var log = new RaftLog(); log.Append(1, "cmd-1"); log.Append(1, "cmd-2"); log.Append(1, "cmd-3"); log.Append(2, "cmd-4"); log.Entries.Count.ShouldBe(4); // Compact up to index 2 — entries 1 and 2 should be removed log.Compact(2); log.Entries.Count.ShouldBe(2); log.Entries[0].Index.ShouldBe(3); log.Entries[1].Index.ShouldBe(4); } [Fact] public void CompactLog_updates_base_index() { var log = new RaftLog(); log.Append(1, "cmd-1"); log.Append(1, "cmd-2"); log.Append(1, "cmd-3"); log.BaseIndex.ShouldBe(0); log.Compact(2); log.BaseIndex.ShouldBe(2); } [Fact] public void CompactLog_with_no_entries_is_noop() { var log = new RaftLog(); log.Entries.Count.ShouldBe(0); log.BaseIndex.ShouldBe(0); // Should not throw or change anything log.Compact(5); log.Entries.Count.ShouldBe(0); log.BaseIndex.ShouldBe(0); } [Fact] public void CompactLog_preserves_append_indexing() { // After compaction, new appends should continue from the correct index var log = new RaftLog(); log.Append(1, "cmd-1"); log.Append(1, "cmd-2"); log.Append(1, "cmd-3"); log.Compact(2); log.BaseIndex.ShouldBe(2); // New entry should get index 4 (baseIndex 2 + 1 remaining entry + 1) var newEntry = log.Append(2, "cmd-4"); newEntry.Index.ShouldBe(4); } // -- Streaming snapshot install on RaftNode -- [Fact] public async Task Streaming_snapshot_install_from_chunks() { // Go reference: raft.go:3500-3700 (installSnapshot with chunked transfer) var node = new RaftNode("n1"); node.Log.Append(1, "cmd-1"); node.Log.Append(1, "cmd-2"); node.Log.Append(1, "cmd-3"); byte[][] chunks = [[1, 2, 3], [4, 5, 6]]; await node.InstallSnapshotFromChunksAsync(chunks, snapshotIndex: 10, snapshotTerm: 3, default); // Log should be replaced (entries cleared, base index set to snapshot) node.Log.Entries.Count.ShouldBe(0); node.AppliedIndex.ShouldBe(10); node.CommitIndex.ShouldBe(10); } [Fact] public async Task Log_after_compaction_starts_at_correct_index() { // After snapshot + compaction, new entries should continue from the right index var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeAsync("cmd-1", default); await leader.ProposeAsync("cmd-2", default); await leader.ProposeAsync("cmd-3", default); leader.Log.Entries.Count.ShouldBe(3); // Create snapshot at current applied index and compact var snapshot = await leader.CreateSnapshotCheckpointAsync(default); snapshot.LastIncludedIndex.ShouldBe(leader.AppliedIndex); // Log should now be empty (all entries covered by snapshot) leader.Log.Entries.Count.ShouldBe(0); leader.Log.BaseIndex.ShouldBe(leader.AppliedIndex); // New entries should continue from the right index var index4 = await leader.ProposeAsync("cmd-4", default); index4.ShouldBe(leader.AppliedIndex); // should be appliedIndex after new propose leader.Log.Entries.Count.ShouldBe(1); } // -- CompactLogAsync on RaftNode -- [Fact] public async Task CompactLogAsync_compacts_up_to_applied_index() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeAsync("cmd-1", default); await leader.ProposeAsync("cmd-2", default); await leader.ProposeAsync("cmd-3", default); leader.Log.Entries.Count.ShouldBe(3); var appliedIndex = leader.AppliedIndex; appliedIndex.ShouldBeGreaterThan(0); await leader.CompactLogAsync(default); // All entries up to applied index should be compacted leader.Log.BaseIndex.ShouldBe(appliedIndex); leader.Log.Entries.Count.ShouldBe(0); } [Fact] public async Task CompactLogAsync_noop_when_nothing_applied() { var node = new RaftNode("n1"); node.AppliedIndex.ShouldBe(0); // Should be a no-op — nothing to compact await node.CompactLogAsync(default); node.Log.BaseIndex.ShouldBe(0); node.Log.Entries.Count.ShouldBe(0); } }