Move 43 Raft consensus test files (8 root-level + 35 in Raft/ subfolder) from NATS.Server.Tests into a dedicated NATS.Server.Raft.Tests project. Update namespaces, add InternalsVisibleTo, and fix timing/exception handling issues in moved test files.
254 lines
7.5 KiB
C#
254 lines
7.5 KiB
C#
using NATS.Server.Raft;
|
|
|
|
namespace NATS.Server.Raft.Tests.Raft;
|
|
|
|
/// <summary>
|
|
/// Tests for B5: Snapshot Checkpoints and Log Compaction.
|
|
/// Go reference: raft.go:3200-3400 (CreateSnapshotCheckpoint), raft.go:3500-3700 (installSnapshot).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|