Files
natsdotnet/src/NATS.Server/Raft/RaftLog.cs
Joseph Doherty 5a62100397 feat: add quorum check before proposing entries (Gap 8.6)
Add HasQuorum() to RaftNode that counts peers with LastContact within
2 × ElectionTimeoutMaxMs and returns true only when self + current peers
reaches majority. ProposeAsync now throws InvalidOperationException with
"no quorum" when HasQuorum() returns false, preventing a partitioned
leader from diverging the log. Add 14 tests in RaftQuorumCheckTests.cs
covering single-node, 3-node, 5-node, boundary window, and heartbeat
restore scenarios. Update RaftHealthTests.LastContact_updates_on_successful_replication
to avoid triggering the new quorum guard.
2026-02-25 08:26:37 -05:00

108 lines
3.4 KiB
C#

namespace NATS.Server.Raft;
public sealed class RaftLog
{
private readonly List<RaftLogEntry> _entries = [];
private long _baseIndex;
public IReadOnlyList<RaftLogEntry> Entries => _entries;
/// <summary>
/// The base index after compaction. Entries before this index have been removed.
/// </summary>
public long BaseIndex => _baseIndex;
public RaftLogEntry Append(int term, string command)
{
var entry = new RaftLogEntry(_baseIndex + _entries.Count + 1, term, command);
_entries.Add(entry);
return entry;
}
/// <summary>
/// Appends an entry with an explicit timestamp. Used in tests and by the
/// <see cref="CompactionPolicy.ByAge"/> policy to set controlled creation times.
/// </summary>
public RaftLogEntry AppendWithTimestamp(int term, string command, DateTime timestamp)
{
var entry = new RaftLogEntry(_baseIndex + _entries.Count + 1, term, command)
{
Timestamp = timestamp,
};
_entries.Add(entry);
return entry;
}
public void AppendReplicated(RaftLogEntry entry)
{
if (_entries.Any(e => e.Index == entry.Index))
return;
_entries.Add(entry);
}
public void ReplaceWithSnapshot(RaftSnapshot snapshot)
{
_entries.Clear();
_baseIndex = snapshot.LastIncludedIndex;
}
/// <summary>
/// Removes all log entries with index &lt;= upToIndex and advances the base index accordingly.
/// This is log compaction: entries covered by a snapshot are discarded.
/// Go reference: raft.go WAL compact / compactLog.
/// </summary>
public void Compact(long upToIndex)
{
var removeCount = _entries.Count(e => e.Index <= upToIndex);
if (removeCount > 0)
{
_entries.RemoveRange(0, removeCount);
_baseIndex = upToIndex;
}
}
public async Task PersistAsync(string path, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var model = new PersistedLog
{
BaseIndex = _baseIndex,
Entries = [.. _entries],
};
await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(model), ct);
}
public static async Task<RaftLog> LoadAsync(string path, CancellationToken ct)
{
var log = new RaftLog();
if (!File.Exists(path))
return log;
var json = await File.ReadAllTextAsync(path, ct);
var model = System.Text.Json.JsonSerializer.Deserialize<PersistedLog>(json) ?? new PersistedLog();
log._baseIndex = model.BaseIndex;
log._entries.AddRange(model.Entries);
return log;
}
private sealed class PersistedLog
{
public long BaseIndex { get; set; }
public List<RaftLogEntry> Entries { get; set; } = [];
}
}
/// <summary>
/// A single RAFT log entry. Timestamp records when the entry was created (UTC) and
/// is used by the <see cref="CompactionPolicy.ByAge"/> compaction policy.
/// </summary>
public sealed record RaftLogEntry(long Index, int Term, string Command)
{
/// <summary>
/// UTC creation time. Defaults to <see cref="DateTime.UtcNow"/> at append time.
/// Stored as a JSON-serializable property so it survives persistence round-trips.
/// </summary>
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
}