Files
natsdotnet/tests/NATS.Server.Tests/Raft/RaftSnapshotTests.cs
Joseph Doherty 3ff801865a feat: Waves 3-5 — FileStore, RAFT, JetStream clustering, and concurrency tests
Add comprehensive Go-parity test coverage across 3 subsystems:
- FileStore: basic CRUD, limits, purge, recovery, subjects, encryption,
  compression, MemStore (161 tests, 24 skipped for not-yet-implemented)
- RAFT: core types, wire format, election, log replication, snapshots
  (95 tests)
- JetStream Clustering: meta controller, stream/consumer replica groups,
  concurrency stress tests (90 tests)

Total: ~346 new test annotations across 17 files (+7,557 lines)
Full suite: 2,606 passing, 0 failures, 27 skipped
2026-02-23 22:55:41 -05:00

426 lines
14 KiB
C#

using System.Text.Json;
using NATS.Server.Raft;
namespace NATS.Server.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);
}
}