using NATS.Server.Raft; namespace NATS.Server.Tests.Raft; /// /// Tests for configurable log compaction policies (Gap 8.5). /// /// Covers enum values, defaults /// and threshold logic, and the integration between /// and the policy engine. /// /// Go reference: raft.go compactLog / WAL compact threshold checks. /// public class RaftCompactionPolicyTests { // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// /// Creates a leader node with proposed entries all applied. /// Each entry command is "cmd-N" so UTF-8 command size is predictable (~6 bytes each). /// private static async Task CreateLeaderWithEntriesAsync(int entryCount, CompactionOptions? compactionOptions = null) { var node = new RaftNode("leader", compactionOptions: compactionOptions); node.ConfigureCluster([node]); node.StartElection(1); for (var i = 1; i <= entryCount; i++) await node.ProposeAsync($"cmd-{i}", default); return node; } /// /// Creates a log entry at an explicit index with an optional custom timestamp. /// Used for ByAge tests where we need to control entry timestamps. /// private static RaftLogEntry MakeEntry(long index, int term, string command, DateTime? timestamp = null) { var entry = new RaftLogEntry(index, term, command); if (timestamp.HasValue) entry = entry with { Timestamp = timestamp.Value }; return entry; } // --------------------------------------------------------------------------- // CompactionOptions default values // --------------------------------------------------------------------------- // Go reference: raft.go compactLog threshold defaults. [Fact] public void CompactionOptions_default_values() { var opts = new CompactionOptions(); opts.Policy.ShouldBe(CompactionPolicy.None); opts.MaxEntries.ShouldBe(10_000); opts.MaxSizeBytes.ShouldBe(100L * 1024 * 1024); opts.MaxAge.ShouldBe(TimeSpan.FromHours(1)); } // --------------------------------------------------------------------------- // None policy — no-op // --------------------------------------------------------------------------- // Go reference: raft.go compactLog — no compaction when policy is None. [Fact] public async Task None_policy_does_not_compact() { var opts = new CompactionOptions { Policy = CompactionPolicy.None }; var node = await CreateLeaderWithEntriesAsync(50, opts); node.Log.Entries.Count.ShouldBe(50); await node.CompactLogAsync(default); // With None policy, nothing should be removed. node.Log.Entries.Count.ShouldBe(50); } // --------------------------------------------------------------------------- // ByCount policy // --------------------------------------------------------------------------- // Go reference: raft.go compactLog — keep at most MaxEntries. [Fact] public async Task ByCount_compacts_when_log_exceeds_max_entries() { const int total = 100; const int keep = 50; var opts = new CompactionOptions { Policy = CompactionPolicy.ByCount, MaxEntries = keep, }; var node = await CreateLeaderWithEntriesAsync(total, opts); // Before compaction: all 100 entries are present. node.Log.Entries.Count.ShouldBe(total); await node.CompactLogAsync(default); // After compaction: only the newest `keep` entries remain. node.Log.Entries.Count.ShouldBe(keep); // The oldest entry's index should be total - keep + 1 node.Log.Entries[0].Index.ShouldBe(total - keep + 1); } // Go reference: raft.go compactLog — do not compact below applied index (safety guard). [Fact] public async Task ByCount_does_not_compact_below_applied_index() { // Build a node but manually set a lower applied index so the safety guard kicks in. const int total = 100; const int keep = 50; var opts = new CompactionOptions { Policy = CompactionPolicy.ByCount, MaxEntries = keep, }; var node = await CreateLeaderWithEntriesAsync(total, opts); // Artificially lower the applied index so the safety guard prevents full compaction. // With applied = 30 the policy wants to compact to index 50 but the guard limits it to 30. node.AppliedIndex = 30; await node.CompactLogAsync(default); // Only entries up to index 30 may be removed. // BaseIndex should be 30, entries with index > 30 remain. node.Log.BaseIndex.ShouldBe(30); node.Log.Entries[0].Index.ShouldBe(31); } // Go reference: raft.go compactLog — no-op when count is within threshold. [Fact] public async Task ByCount_does_not_compact_when_within_threshold() { const int total = 20; const int maxEntries = 50; // more than total — nothing to compact var opts = new CompactionOptions { Policy = CompactionPolicy.ByCount, MaxEntries = maxEntries, }; var node = await CreateLeaderWithEntriesAsync(total, opts); await node.CompactLogAsync(default); node.Log.Entries.Count.ShouldBe(total); } // --------------------------------------------------------------------------- // BySize policy // --------------------------------------------------------------------------- // Go reference: raft.go compactLog — compact oldest entries until under size threshold. [Fact] public void BySize_compacts_when_total_size_exceeds_threshold() { // Construct a log manually so we control sizes precisely. // Each entry: index(8) + term(4) + command(N bytes). // Use a 100-byte command to make size maths easy. var log = new RaftLog(); var largeCommand = new string('x', 100); // 100 UTF-8 bytes // Append 10 entries — each ~112 bytes → total ~1,120 bytes. for (var i = 1; i <= 10; i++) log.Append(term: 1, command: largeCommand); // Set threshold at ~600 bytes — roughly half the total — so the oldest ~5 entries // should be compacted. var opts = new CompactionOptions { Policy = CompactionPolicy.BySize, MaxSizeBytes = 600, }; // appliedIndex = last entry index (all applied) var appliedIndex = log.Entries[^1].Index; var cutoff = opts.ComputeCompactionIndex(log, appliedIndex); // Should return a positive index, meaning some entries must be compacted. cutoff.ShouldBeGreaterThan(0); cutoff.ShouldBeLessThanOrEqualTo(appliedIndex); log.Compact(cutoff); // After compaction fewer entries should remain. log.Entries.Count.ShouldBeLessThan(10); log.Entries.Count.ShouldBeGreaterThan(0); } // Go reference: raft.go compactLog — no-op when total size is under threshold. [Fact] public void BySize_does_not_compact_when_under_threshold() { var log = new RaftLog(); for (var i = 1; i <= 5; i++) log.Append(term: 1, command: "tiny"); // Threshold large enough to fit everything. var opts = new CompactionOptions { Policy = CompactionPolicy.BySize, MaxSizeBytes = 10_000, }; var cutoff = opts.ComputeCompactionIndex(log, log.Entries[^1].Index); cutoff.ShouldBe(-1); // no compaction } // --------------------------------------------------------------------------- // ByAge policy // --------------------------------------------------------------------------- // Go reference: raft.go compactLog — compact entries older than MaxAge. [Fact] public void ByAge_compacts_old_entries() { // Build a log manually with some old and some fresh entries. var log = new RaftLog(); var old = DateTime.UtcNow - TimeSpan.FromHours(2); var fresh = DateTime.UtcNow - TimeSpan.FromMinutes(1); // Entries 1–5: old (2 hours ago) for (var i = 1; i <= 5; i++) log.AppendWithTimestamp(term: 1, command: $"old-{i}", timestamp: old); // Entries 6–10: fresh (1 minute ago) for (var i = 6; i <= 10; i++) log.AppendWithTimestamp(term: 1, command: $"fresh-{i}", timestamp: fresh); var opts = new CompactionOptions { Policy = CompactionPolicy.ByAge, MaxAge = TimeSpan.FromHours(1), // default; entries older than 1h are eligible }; var appliedIndex = log.Entries[^1].Index; var cutoff = opts.ComputeCompactionIndex(log, appliedIndex); // Should compact through index 5 (the last old entry). cutoff.ShouldBe(5); log.Compact(cutoff); log.Entries.Count.ShouldBe(5); // only the fresh entries remain log.Entries[0].Index.ShouldBe(6); } // Go reference: raft.go compactLog — no-op when all entries are still fresh. [Fact] public void ByAge_does_not_compact_fresh_entries() { var log = new RaftLog(); var fresh = DateTime.UtcNow - TimeSpan.FromMinutes(5); for (var i = 1; i <= 10; i++) log.AppendWithTimestamp(term: 1, command: $"cmd-{i}", timestamp: fresh); var opts = new CompactionOptions { Policy = CompactionPolicy.ByAge, MaxAge = TimeSpan.FromHours(1), }; var cutoff = opts.ComputeCompactionIndex(log, log.Entries[^1].Index); cutoff.ShouldBe(-1); // nothing old enough } // --------------------------------------------------------------------------- // CompactLogAsync integration tests // --------------------------------------------------------------------------- // Go reference: raft.go CompactLog — safety guard: never exceed applied index. [Fact] public async Task CompactLogAsync_with_policy_respects_applied_index() { // Node with 50 entries. Policy wants to compact to index 40, but applied is only 25. const int total = 50; var opts = new CompactionOptions { Policy = CompactionPolicy.ByCount, MaxEntries = 10, // wants to compact to index 40 }; var node = await CreateLeaderWithEntriesAsync(total, opts); // Override applied index to a lower value than the policy cutoff. node.AppliedIndex = 25; await node.CompactLogAsync(default); // Compaction must not go past applied index = 25. node.Log.BaseIndex.ShouldBeLessThanOrEqualTo(25); // All entries above index 25 must still be present. node.Log.Entries.ShouldAllBe(e => e.Index > 25); } // Go reference: raft.go CompactLog — when no policy is set, compacts to applied index. [Fact] public async Task CompactLogAsync_without_policy_compacts_to_applied_index() { var node = await CreateLeaderWithEntriesAsync(30); // No CompactionOptions set. await node.CompactLogAsync(default); // All entries compacted to the applied index. node.Log.Entries.Count.ShouldBe(0); node.Log.BaseIndex.ShouldBe(node.AppliedIndex); } // Go reference: raft.go CompactLog — per-call options override node-level options. [Fact] public async Task CompactLogAsync_call_level_options_override_node_level_options() { // Node has None policy (no auto-compaction). var nodeOpts = new CompactionOptions { Policy = CompactionPolicy.None }; var node = await CreateLeaderWithEntriesAsync(50, nodeOpts); // But the call-level override uses ByCount with MaxEntries = 25. var callOpts = new CompactionOptions { Policy = CompactionPolicy.ByCount, MaxEntries = 25, }; await node.CompactLogAsync(default, callOpts); // Call-level option must win: 25 entries kept. node.Log.Entries.Count.ShouldBe(25); } }