feat(raft): add membership proposals, snapshot checkpoints, and log compaction (B4+B5+B6)

- ProposeAddPeerAsync/ProposeRemovePeerAsync: single-change-at-a-time membership
  changes through RAFT consensus (Go ref: raft.go:961-1019)
- RaftLog.Compact: removes entries up to given index for log compaction
- CreateSnapshotCheckpointAsync: creates snapshot and compacts log in one operation
- DrainAndReplaySnapshotAsync: drains commit queue, installs snapshot, resets indices
- Pre-vote protocol skipped (Go NATS doesn't implement it either)
- 23 new tests in RaftMembershipAndSnapshotTests
This commit is contained in:
Joseph Doherty
2026-02-24 17:06:16 -05:00
parent 5b706c969d
commit 824e0b3607
10 changed files with 1712 additions and 2 deletions

View File

@@ -356,6 +356,93 @@ public readonly record struct RaftAppendEntryResponseWire(
}
}
/// <summary>
/// Binary wire encoding of a RAFT Pre-Vote request.
/// Same layout as VoteRequest (32 bytes) — Go uses same encoding for pre-vote.
/// The pre-vote round does NOT increment the term; it tests whether a candidate
/// would win an election before disrupting the cluster.
/// Go reference: raft.go:1600-1700 (pre-vote logic)
/// </summary>
public readonly record struct RaftPreVoteRequestWire(
ulong Term,
ulong LastTerm,
ulong LastIndex,
string CandidateId)
{
/// <summary>
/// Encodes this PreVoteRequest to a 32-byte little-endian buffer.
/// Same layout as VoteRequest.
/// </summary>
public byte[] Encode()
{
var buf = new byte[RaftWireConstants.VoteRequestLen];
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(0), Term);
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(8), LastTerm);
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(16), LastIndex);
RaftWireHelpers.WriteId(buf.AsSpan(24), CandidateId);
return buf;
}
/// <summary>
/// Decodes a PreVoteRequest from a span. Throws <see cref="ArgumentException"/>
/// if the span is not exactly 32 bytes.
/// </summary>
public static RaftPreVoteRequestWire Decode(ReadOnlySpan<byte> msg)
{
if (msg.Length != RaftWireConstants.VoteRequestLen)
throw new ArgumentException(
$"PreVoteRequest requires exactly {RaftWireConstants.VoteRequestLen} bytes, got {msg.Length}.",
nameof(msg));
return new RaftPreVoteRequestWire(
Term: BinaryPrimitives.ReadUInt64LittleEndian(msg[0..]),
LastTerm: BinaryPrimitives.ReadUInt64LittleEndian(msg[8..]),
LastIndex: BinaryPrimitives.ReadUInt64LittleEndian(msg[16..]),
CandidateId: RaftWireHelpers.ReadId(msg[24..]));
}
}
/// <summary>
/// Binary wire encoding of a RAFT Pre-Vote response.
/// Same layout as VoteResponse (17 bytes) with Empty always false.
/// Go reference: raft.go:1600-1700 (pre-vote logic)
/// </summary>
public readonly record struct RaftPreVoteResponseWire(
ulong Term,
string PeerId,
bool Granted)
{
/// <summary>
/// Encodes this PreVoteResponse to a 17-byte buffer.
/// Same layout as VoteResponse with Empty flag always false.
/// </summary>
public byte[] Encode()
{
var buf = new byte[RaftWireConstants.VoteResponseLen];
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(0), Term);
RaftWireHelpers.WriteId(buf.AsSpan(8), PeerId);
buf[16] = Granted ? (byte)1 : (byte)0;
return buf;
}
/// <summary>
/// Decodes a PreVoteResponse from a span. Throws <see cref="ArgumentException"/>
/// if the span is not exactly 17 bytes.
/// </summary>
public static RaftPreVoteResponseWire Decode(ReadOnlySpan<byte> msg)
{
if (msg.Length != RaftWireConstants.VoteResponseLen)
throw new ArgumentException(
$"PreVoteResponse requires exactly {RaftWireConstants.VoteResponseLen} bytes, got {msg.Length}.",
nameof(msg));
return new RaftPreVoteResponseWire(
Term: BinaryPrimitives.ReadUInt64LittleEndian(msg[0..]),
PeerId: RaftWireHelpers.ReadId(msg[8..]),
Granted: (msg[16] & 1) != 0);
}
}
/// <summary>
/// Shared encoding helpers for all RAFT wire format types.
/// </summary>