// Go parity: golang/nats-server/server/jetstream_cluster.go // Covers: StreamReplicaGroup construction from StreamAssignment, per-stream RAFT apply // logic (processStreamEntries), checkpoint/restore snapshot lifecycle, and commit/processed // index tracking through the group facade. using NATS.Server.JetStream.Cluster; using NATS.Server.Raft; namespace NATS.Server.JetStream.Tests.JetStream.Cluster; /// /// Tests for B10: per-stream RAFT apply logic added to StreamReplicaGroup. /// Covers construction from StreamAssignment, apply loop, snapshot checkpoint/restore, /// and the CommitIndex/ProcessedIndex/PendingCommits facade properties. /// Go reference: jetstream_cluster.go processStreamAssignment, processStreamEntries. /// public class StreamReplicaGroupApplyTests { // --------------------------------------------------------------- // Go: jetstream_cluster.go processStreamAssignment — builds per-stream raft group // --------------------------------------------------------------- [Fact] public void Construction_from_assignment_creates_correct_number_of_nodes() { var assignment = new StreamAssignment { StreamName = "ORDERS", Group = new RaftGroup { Name = "orders-raft", Peers = ["n1", "n2", "n3"], }, }; var group = new StreamReplicaGroup(assignment); group.Nodes.Count.ShouldBe(3); group.StreamName.ShouldBe("ORDERS"); group.Assignment.ShouldNotBeNull(); group.Assignment!.StreamName.ShouldBe("ORDERS"); } [Fact] public void Construction_from_assignment_uses_peer_ids_as_node_ids() { var assignment = new StreamAssignment { StreamName = "EVENTS", Group = new RaftGroup { Name = "events-raft", Peers = ["peer-a", "peer-b", "peer-c"], }, }; var group = new StreamReplicaGroup(assignment); var nodeIds = group.Nodes.Select(n => n.Id).ToHashSet(); nodeIds.ShouldContain("peer-a"); nodeIds.ShouldContain("peer-b"); nodeIds.ShouldContain("peer-c"); } [Fact] public void Construction_from_assignment_elects_leader() { var assignment = new StreamAssignment { StreamName = "STREAM", Group = new RaftGroup { Name = "stream-raft", Peers = ["n1", "n2", "n3"], }, }; var group = new StreamReplicaGroup(assignment); group.Leader.ShouldNotBeNull(); group.Leader.IsLeader.ShouldBeTrue(); } [Fact] public void Construction_from_assignment_with_no_peers_creates_single_node() { var assignment = new StreamAssignment { StreamName = "SOLO", Group = new RaftGroup { Name = "solo-raft" }, }; var group = new StreamReplicaGroup(assignment); group.Nodes.Count.ShouldBe(1); group.Leader.IsLeader.ShouldBeTrue(); } // --------------------------------------------------------------- // Go: raft.go:150-160 (applied/processed fields) — commit index on proposal // --------------------------------------------------------------- [Fact] public async Task ProposeAsync_through_group_increments_commit_index() { var group = new StreamReplicaGroup("TRACK", replicas: 3); group.CommitIndex.ShouldBe(0); await group.ProposeAsync("msg.1", default); group.CommitIndex.ShouldBe(1); } [Fact] public async Task Multiple_proposals_increment_commit_index_monotonically() { var group = new StreamReplicaGroup("MULTI", replicas: 3); await group.ProposeAsync("msg.1", default); await group.ProposeAsync("msg.2", default); await group.ProposeAsync("msg.3", default); group.CommitIndex.ShouldBe(3); } // --------------------------------------------------------------- // Go: jetstream_cluster.go processStreamEntries — apply loop // --------------------------------------------------------------- [Fact] public async Task ApplyCommittedEntriesAsync_processes_pending_entries() { var group = new StreamReplicaGroup("APPLY", replicas: 3); await group.ProposeAsync("store.msg.1", default); await group.ProposeAsync("store.msg.2", default); group.PendingCommits.ShouldBe(2); await group.ApplyCommittedEntriesAsync(default); group.PendingCommits.ShouldBe(0); group.ProcessedIndex.ShouldBe(2); } [Fact] public async Task ApplyCommittedEntriesAsync_marks_regular_entries_as_processed() { var group = new StreamReplicaGroup("MARK", replicas: 1); var idx = await group.ProposeAsync("data.record", default); group.ProcessedIndex.ShouldBe(0); await group.ApplyCommittedEntriesAsync(default); group.ProcessedIndex.ShouldBe(idx); } [Fact] public async Task ApplyCommittedEntriesAsync_on_empty_queue_is_noop() { var group = new StreamReplicaGroup("EMPTY", replicas: 3); // No proposals — queue is empty, should not throw await group.ApplyCommittedEntriesAsync(default); group.ProcessedIndex.ShouldBe(0); group.PendingCommits.ShouldBe(0); } // --------------------------------------------------------------- // Go: raft.go CreateSnapshotCheckpoint — snapshot lifecycle // --------------------------------------------------------------- [Fact] public async Task CheckpointAsync_creates_snapshot_at_current_state() { var group = new StreamReplicaGroup("SNAP", replicas: 3); await group.ProposeAsync("entry.1", default); await group.ProposeAsync("entry.2", default); var snapshot = await group.CheckpointAsync(default); snapshot.ShouldNotBeNull(); snapshot.LastIncludedIndex.ShouldBeGreaterThan(0); } [Fact] public async Task CheckpointAsync_snapshot_index_matches_applied_index() { var group = new StreamReplicaGroup("SNAPIDX", replicas: 1); await group.ProposeAsync("record.1", default); await group.ProposeAsync("record.2", default); var snapshot = await group.CheckpointAsync(default); snapshot.LastIncludedIndex.ShouldBe(group.Leader.AppliedIndex); } // --------------------------------------------------------------- // Go: raft.go DrainAndReplaySnapshot — restore lifecycle // --------------------------------------------------------------- [Fact] public async Task RestoreFromSnapshotAsync_restores_state() { var group = new StreamReplicaGroup("RESTORE", replicas: 3); await group.ProposeAsync("pre.1", default); await group.ProposeAsync("pre.2", default); var snapshot = await group.CheckpointAsync(default); // Advance state further after snapshot await group.ProposeAsync("post.1", default); // Restore: should drain queue and roll back to snapshot state await group.RestoreFromSnapshotAsync(snapshot, default); // After restore the commit index reflects the snapshot group.CommitIndex.ShouldBe(snapshot.LastIncludedIndex); // Pending commits should be drained group.PendingCommits.ShouldBe(0); } [Fact] public async Task RestoreFromSnapshotAsync_drains_pending_commits() { var group = new StreamReplicaGroup("DRAIN", replicas: 3); // Propose several entries so queue has items await group.ProposeAsync("queued.1", default); await group.ProposeAsync("queued.2", default); await group.ProposeAsync("queued.3", default); group.PendingCommits.ShouldBeGreaterThan(0); var snapshot = new RaftSnapshot { LastIncludedIndex = 3, LastIncludedTerm = group.Leader.Term, }; await group.RestoreFromSnapshotAsync(snapshot, default); group.PendingCommits.ShouldBe(0); } // --------------------------------------------------------------- // Go: raft.go:150-160 — PendingCommits reflects commit queue depth // --------------------------------------------------------------- [Fact] public async Task PendingCommits_reflects_commit_queue_depth() { var group = new StreamReplicaGroup("QUEUE", replicas: 3); group.PendingCommits.ShouldBe(0); await group.ProposeAsync("q.1", default); group.PendingCommits.ShouldBe(1); await group.ProposeAsync("q.2", default); group.PendingCommits.ShouldBe(2); await group.ApplyCommittedEntriesAsync(default); group.PendingCommits.ShouldBe(0); } // --------------------------------------------------------------- // Go: raft.go applied/processed tracking — CommitIndex and ProcessedIndex // --------------------------------------------------------------- [Fact] public async Task CommitIndex_and_ProcessedIndex_track_through_the_group() { var group = new StreamReplicaGroup("INDICES", replicas: 3); group.CommitIndex.ShouldBe(0); group.ProcessedIndex.ShouldBe(0); await group.ProposeAsync("step.1", default); group.CommitIndex.ShouldBe(1); // Not yet applied group.ProcessedIndex.ShouldBe(0); await group.ApplyCommittedEntriesAsync(default); group.ProcessedIndex.ShouldBe(1); await group.ProposeAsync("step.2", default); group.CommitIndex.ShouldBe(2); group.ProcessedIndex.ShouldBe(1); // still only first entry applied await group.ApplyCommittedEntriesAsync(default); group.ProcessedIndex.ShouldBe(2); } [Fact] public void CommitIndex_initially_zero_for_fresh_group() { var group = new StreamReplicaGroup("FRESH", replicas: 5); group.CommitIndex.ShouldBe(0); group.ProcessedIndex.ShouldBe(0); group.PendingCommits.ShouldBe(0); } }