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.
108 lines
3.4 KiB
C#
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 <= 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;
|
|
}
|