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.
426 lines
14 KiB
C#
426 lines
14 KiB
C#
using System.Text.Json;
|
|
using NATS.Server.Raft;
|
|
|
|
namespace NATS.Server.Raft.Tests.Raft;
|
|
|
|
/// <summary>
|
|
/// Snapshot tests covering creation, restore, transfer, membership changes during
|
|
/// snapshot, snapshot store persistence, and leader/follower catchup via snapshots.
|
|
/// Go: TestNRGSnapshotAndRestart, TestNRGRemoveLeaderPeerDeadlockBug,
|
|
/// TestNRGLeaderTransfer in server/raft_test.go.
|
|
/// </summary>
|
|
public class RaftSnapshotTests
|
|
{
|
|
// -- Helpers (self-contained) --
|
|
|
|
private static (RaftNode leader, RaftNode[] followers) CreateLeaderWithFollowers(int followerCount)
|
|
{
|
|
var total = followerCount + 1;
|
|
var nodes = Enumerable.Range(1, total)
|
|
.Select(i => new RaftNode($"n{i}"))
|
|
.ToArray();
|
|
foreach (var node in nodes)
|
|
node.ConfigureCluster(nodes);
|
|
|
|
var candidate = nodes[0];
|
|
candidate.StartElection(total);
|
|
foreach (var voter in nodes.Skip(1))
|
|
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), total);
|
|
|
|
return (candidate, nodes.Skip(1).ToArray());
|
|
}
|
|
|
|
private static (RaftNode leader, RaftNode[] followers, InMemoryRaftTransport transport) CreateTransportCluster(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);
|
|
}
|
|
|
|
var candidate = nodes[0];
|
|
candidate.StartElection(size);
|
|
foreach (var voter in nodes.Skip(1))
|
|
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), size);
|
|
|
|
return (candidate, nodes.Skip(1).ToArray(), transport);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot creation
|
|
[Fact]
|
|
public async Task Create_snapshot_captures_applied_index_and_term()
|
|
{
|
|
var (leader, _) = CreateLeaderWithFollowers(2);
|
|
await leader.ProposeAsync("cmd-1", default);
|
|
await leader.ProposeAsync("cmd-2", default);
|
|
|
|
var snapshot = await leader.CreateSnapshotAsync(default);
|
|
|
|
snapshot.LastIncludedIndex.ShouldBe(leader.AppliedIndex);
|
|
snapshot.LastIncludedTerm.ShouldBe(leader.Term);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — install snapshot
|
|
[Fact]
|
|
public async Task Install_snapshot_updates_applied_index()
|
|
{
|
|
var (leader, followers) = CreateLeaderWithFollowers(2);
|
|
await leader.ProposeAsync("snap-cmd-1", default);
|
|
await leader.ProposeAsync("snap-cmd-2", default);
|
|
await leader.ProposeAsync("snap-cmd-3", default);
|
|
|
|
var snapshot = await leader.CreateSnapshotAsync(default);
|
|
var newFollower = new RaftNode("new-follower");
|
|
|
|
await newFollower.InstallSnapshotAsync(snapshot, default);
|
|
|
|
newFollower.AppliedIndex.ShouldBe(snapshot.LastIncludedIndex);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot clears log
|
|
[Fact]
|
|
public async Task Install_snapshot_clears_existing_log()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.Log.Append(term: 1, command: "old-1");
|
|
node.Log.Append(term: 1, command: "old-2");
|
|
node.Log.Entries.Count.ShouldBe(2);
|
|
|
|
var snapshot = new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 10,
|
|
LastIncludedTerm = 3,
|
|
};
|
|
|
|
await node.InstallSnapshotAsync(snapshot, default);
|
|
|
|
node.Log.Entries.Count.ShouldBe(0);
|
|
node.AppliedIndex.ShouldBe(10);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — new entries after snapshot
|
|
[Fact]
|
|
public async Task Entries_after_snapshot_start_at_correct_index()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
|
|
var snapshot = new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 50,
|
|
LastIncludedTerm = 5,
|
|
};
|
|
|
|
await node.InstallSnapshotAsync(snapshot, default);
|
|
|
|
var entry = node.Log.Append(term: 6, command: "post-snap");
|
|
entry.Index.ShouldBe(51);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot transfer
|
|
[Fact]
|
|
public async Task Snapshot_transfer_via_transport()
|
|
{
|
|
var (leader, followers, transport) = CreateTransportCluster(3);
|
|
await leader.ProposeAsync("entry-1", default);
|
|
await leader.ProposeAsync("entry-2", default);
|
|
|
|
var snapshot = await leader.CreateSnapshotAsync(default);
|
|
|
|
// Transfer to a follower
|
|
var follower = followers[0];
|
|
await transport.InstallSnapshotAsync(leader.Id, follower.Id, snapshot, default);
|
|
|
|
follower.AppliedIndex.ShouldBe(snapshot.LastIncludedIndex);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — lagging follower catchup
|
|
[Fact]
|
|
public async Task Lagging_follower_catches_up_via_snapshot()
|
|
{
|
|
var (leader, followers) = CreateLeaderWithFollowers(2);
|
|
|
|
// Leader has entries, follower is behind
|
|
await leader.ProposeAsync("catchup-1", default);
|
|
await leader.ProposeAsync("catchup-2", default);
|
|
await leader.ProposeAsync("catchup-3", default);
|
|
|
|
var laggingFollower = new RaftNode("lagging");
|
|
laggingFollower.AppliedIndex.ShouldBe(0);
|
|
|
|
var snapshot = await leader.CreateSnapshotAsync(default);
|
|
await laggingFollower.InstallSnapshotAsync(snapshot, default);
|
|
|
|
laggingFollower.AppliedIndex.ShouldBe(leader.AppliedIndex);
|
|
}
|
|
|
|
// Go: RaftSnapshotStore — in-memory save/load
|
|
[Fact]
|
|
public async Task Snapshot_store_in_memory_save_and_load()
|
|
{
|
|
var store = new RaftSnapshotStore();
|
|
|
|
var snapshot = new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 42,
|
|
LastIncludedTerm = 7,
|
|
Data = [1, 2, 3],
|
|
};
|
|
|
|
await store.SaveAsync(snapshot, default);
|
|
var loaded = await store.LoadAsync(default);
|
|
|
|
loaded.ShouldNotBeNull();
|
|
loaded.LastIncludedIndex.ShouldBe(42);
|
|
loaded.LastIncludedTerm.ShouldBe(7);
|
|
loaded.Data.ShouldBe(new byte[] { 1, 2, 3 });
|
|
}
|
|
|
|
// Go: RaftSnapshotStore — file-based save/load
|
|
[Fact]
|
|
public async Task Snapshot_store_file_based_persistence()
|
|
{
|
|
var file = Path.Combine(Path.GetTempPath(), $"nats-raft-snap-{Guid.NewGuid():N}.json");
|
|
|
|
try
|
|
{
|
|
var store1 = new RaftSnapshotStore(file);
|
|
await store1.SaveAsync(new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 100,
|
|
LastIncludedTerm = 10,
|
|
Data = [99, 88, 77],
|
|
}, default);
|
|
|
|
// New store instance, load from file
|
|
var store2 = new RaftSnapshotStore(file);
|
|
var loaded = await store2.LoadAsync(default);
|
|
|
|
loaded.ShouldNotBeNull();
|
|
loaded.LastIncludedIndex.ShouldBe(100);
|
|
loaded.LastIncludedTerm.ShouldBe(10);
|
|
loaded.Data.ShouldBe(new byte[] { 99, 88, 77 });
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(file))
|
|
File.Delete(file);
|
|
}
|
|
}
|
|
|
|
// Go: RaftSnapshotStore — load from nonexistent returns null
|
|
[Fact]
|
|
public async Task Snapshot_store_load_nonexistent_returns_null()
|
|
{
|
|
var store = new RaftSnapshotStore();
|
|
var loaded = await store.LoadAsync(default);
|
|
loaded.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestNRGRemoveLeaderPeerDeadlockBug server/raft_test.go:1040 — membership add
|
|
[Fact]
|
|
public void Membership_add_member()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.Members.ShouldContain("n1"); // self is auto-added
|
|
|
|
node.AddMember("n2");
|
|
node.AddMember("n3");
|
|
node.Members.ShouldContain("n2");
|
|
node.Members.ShouldContain("n3");
|
|
node.Members.Count.ShouldBe(3);
|
|
}
|
|
|
|
// Go: TestNRGRemoveLeaderPeerDeadlockBug server/raft_test.go:1040 — membership remove
|
|
[Fact]
|
|
public void Membership_remove_member()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.AddMember("n2");
|
|
node.AddMember("n3");
|
|
|
|
node.RemoveMember("n2");
|
|
node.Members.ShouldNotContain("n2");
|
|
node.Members.ShouldContain("n1");
|
|
node.Members.ShouldContain("n3");
|
|
}
|
|
|
|
// Go: TestNRGRemoveLeaderPeerDeadlockBug server/raft_test.go:1040
|
|
[Fact]
|
|
public void Remove_nonexistent_member_is_noop()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.RemoveMember("nonexistent"); // should not throw
|
|
node.Members.Count.ShouldBe(1); // still just self
|
|
}
|
|
|
|
// Go: ConfigureCluster in RaftNode
|
|
[Fact]
|
|
public void Configure_cluster_sets_members()
|
|
{
|
|
var n1 = new RaftNode("n1");
|
|
var n2 = new RaftNode("n2");
|
|
var n3 = new RaftNode("n3");
|
|
var nodes = new[] { n1, n2, n3 };
|
|
|
|
n1.ConfigureCluster(nodes);
|
|
|
|
n1.Members.ShouldContain("n1");
|
|
n1.Members.ShouldContain("n2");
|
|
n1.Members.ShouldContain("n3");
|
|
}
|
|
|
|
// Go: TestNRGLeaderTransfer server/raft_test.go:377 — leadership transfer
|
|
[Fact]
|
|
public async Task Leadership_transfer_via_stepdown_and_reelection()
|
|
{
|
|
var (leader, followers) = CreateLeaderWithFollowers(2);
|
|
leader.IsLeader.ShouldBeTrue();
|
|
|
|
var preferredNode = followers[0];
|
|
|
|
// Leader steps down
|
|
leader.RequestStepDown();
|
|
leader.IsLeader.ShouldBeFalse();
|
|
|
|
// Preferred node runs election
|
|
var allNodes = new[] { leader }.Concat(followers).ToArray();
|
|
preferredNode.StartElection(allNodes.Length);
|
|
|
|
foreach (var voter in allNodes.Where(n => n.Id != preferredNode.Id))
|
|
{
|
|
var vote = voter.GrantVote(preferredNode.Term, preferredNode.Id);
|
|
preferredNode.ReceiveVote(vote, allNodes.Length);
|
|
}
|
|
|
|
preferredNode.IsLeader.ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot with data payload
|
|
[Fact]
|
|
public void Snapshot_with_large_data_payload()
|
|
{
|
|
var data = new byte[1024 * 64]; // 64KB
|
|
Random.Shared.NextBytes(data);
|
|
|
|
var snapshot = new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 500,
|
|
LastIncludedTerm = 20,
|
|
Data = data,
|
|
};
|
|
|
|
snapshot.Data.Length.ShouldBe(1024 * 64);
|
|
snapshot.LastIncludedIndex.ShouldBe(500);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot JSON round-trip
|
|
[Fact]
|
|
public void Snapshot_json_serialization_round_trip()
|
|
{
|
|
var data = new byte[] { 10, 20, 30, 40, 50 };
|
|
var snapshot = new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 75,
|
|
LastIncludedTerm = 8,
|
|
Data = data,
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(snapshot);
|
|
var decoded = JsonSerializer.Deserialize<RaftSnapshot>(json);
|
|
|
|
decoded.ShouldNotBeNull();
|
|
decoded.LastIncludedIndex.ShouldBe(75);
|
|
decoded.LastIncludedTerm.ShouldBe(8);
|
|
decoded.Data.ShouldBe(data);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — full cluster snapshot + restart
|
|
[Fact]
|
|
public async Task Full_cluster_snapshot_and_follower_restart()
|
|
{
|
|
var (leader, followers) = CreateLeaderWithFollowers(2);
|
|
|
|
await leader.ProposeAsync("pre-snap-1", default);
|
|
await leader.ProposeAsync("pre-snap-2", default);
|
|
await leader.ProposeAsync("pre-snap-3", default);
|
|
|
|
var snapshot = await leader.CreateSnapshotAsync(default);
|
|
|
|
// Simulate follower restart by installing snapshot on fresh node
|
|
var restartedFollower = new RaftNode("restarted");
|
|
await restartedFollower.InstallSnapshotAsync(snapshot, default);
|
|
|
|
restartedFollower.AppliedIndex.ShouldBe(snapshot.LastIncludedIndex);
|
|
restartedFollower.Log.Entries.Count.ShouldBe(0); // log was replaced by snapshot
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot replaces stale log
|
|
[Fact]
|
|
public async Task Snapshot_replaces_stale_log_entries()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.Log.Append(term: 1, command: "stale-1");
|
|
node.Log.Append(term: 1, command: "stale-2");
|
|
node.Log.Append(term: 1, command: "stale-3");
|
|
|
|
var snapshot = new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 100,
|
|
LastIncludedTerm = 5,
|
|
};
|
|
|
|
await node.InstallSnapshotAsync(snapshot, default);
|
|
|
|
node.Log.Entries.Count.ShouldBe(0);
|
|
node.AppliedIndex.ShouldBe(100);
|
|
|
|
// New entries continue from snapshot base
|
|
var newEntry = node.Log.Append(term: 6, command: "fresh");
|
|
newEntry.Index.ShouldBe(101);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot store overwrites previous
|
|
[Fact]
|
|
public async Task Snapshot_store_overwrites_previous_snapshot()
|
|
{
|
|
var store = new RaftSnapshotStore();
|
|
|
|
await store.SaveAsync(new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 }, default);
|
|
await store.SaveAsync(new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 3 }, default);
|
|
|
|
var loaded = await store.LoadAsync(default);
|
|
loaded.ShouldNotBeNull();
|
|
loaded.LastIncludedIndex.ShouldBe(50);
|
|
loaded.LastIncludedTerm.ShouldBe(3);
|
|
}
|
|
|
|
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — node state after multiple snapshots
|
|
[Fact]
|
|
public async Task Multiple_snapshot_installs_advance_applied_index()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
|
|
await node.InstallSnapshotAsync(new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 10,
|
|
LastIncludedTerm = 1,
|
|
}, default);
|
|
node.AppliedIndex.ShouldBe(10);
|
|
|
|
await node.InstallSnapshotAsync(new RaftSnapshot
|
|
{
|
|
LastIncludedIndex = 50,
|
|
LastIncludedTerm = 3,
|
|
}, default);
|
|
node.AppliedIndex.ShouldBe(50);
|
|
|
|
// Entries start after latest snapshot
|
|
var entry = node.Log.Append(term: 4, command: "after-second-snap");
|
|
entry.Index.ShouldBe(51);
|
|
}
|
|
}
|