Files
natsdotnet/tests/NATS.Server.Raft.Tests/Raft/RaftCompactionPolicyTests.cs
Joseph Doherty edf9ed770e refactor: extract NATS.Server.Raft.Tests project
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.
2026-03-12 15:36:02 -04:00

345 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using NATS.Server.Raft;
namespace NATS.Server.Raft.Tests.Raft;
/// <summary>
/// Tests for configurable log compaction policies (Gap 8.5).
///
/// Covers <see cref="CompactionPolicy"/> enum values, <see cref="CompactionOptions"/> defaults
/// and threshold logic, and the integration between <see cref="RaftNode.CompactLogAsync"/>
/// and the policy engine.
///
/// Go reference: raft.go compactLog / WAL compact threshold checks.
/// </summary>
public class RaftCompactionPolicyTests
{
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// <summary>
/// Creates a leader node with <paramref name="entryCount"/> proposed entries all applied.
/// Each entry command is "cmd-N" so UTF-8 command size is predictable (~6 bytes each).
/// </summary>
private static async Task<RaftNode> 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;
}
/// <summary>
/// Creates a log entry at an explicit index with an optional custom timestamp.
/// Used for ByAge tests where we need to control entry timestamps.
/// </summary>
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 15: old (2 hours ago)
for (var i = 1; i <= 5; i++)
log.AppendWithTimestamp(term: 1, command: $"old-{i}", timestamp: old);
// Entries 610: 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);
}
}