feat: add randomized election timeout jitter (Gap 8.8)

Add RandomizedElectionTimeout() method to RaftNode returning TimeSpan in
[ElectionTimeoutMinMs, ElectionTimeoutMaxMs) using TotalMilliseconds (not
.Milliseconds component) to prevent synchronized elections after partitions.
Make Random injectable for deterministic testing. Fix SendHeartbeatAsync stub
in NatsRaftTransport and test-local transport implementations to satisfy the
IRaftTransport interface added in Gap 8.7.
This commit is contained in:
Joseph Doherty
2026-02-25 08:30:38 -05:00
parent 5a62100397
commit ae4cc6d613
7 changed files with 355 additions and 3 deletions

View File

@@ -217,4 +217,42 @@ public sealed class NatsRaftTransport : IRaftTransport
_publish(subject, null, wire.Encode());
return Task.CompletedTask;
}
/// <summary>
/// Sends heartbeat RPCs to all listed followers over NATS to confirm quorum for
/// linearizable reads. Publishes an empty AppendEntry to each follower's heartbeat
/// subject and invokes <paramref name="onAck"/> for each that is considered reachable
/// (fire-and-forget in this transport; ACK is optimistic).
///
/// Go reference: raft.go — leader sends empty AppendEntries to confirm quorum for reads.
/// </summary>
public Task SendHeartbeatAsync(
string leaderId,
IReadOnlyList<string> followerIds,
int term,
Action<string> onAck,
CancellationToken ct)
{
var appendSubject = RaftSubjects.AppendEntry(_groupId);
foreach (var followerId in followerIds)
{
// Encode a heartbeat as an empty AppendEntry (no log entries).
var wire = new RaftAppendEntryWire(
LeaderId: leaderId,
Term: (ulong)term,
Commit: 0,
PrevTerm: 0,
PrevIndex: 0,
Entries: [],
LeaderTerm: (ulong)term);
_publish(appendSubject, null, wire.Encode());
// Optimistically acknowledge — a full implementation would await replies.
onAck(followerId);
}
return Task.CompletedTask;
}
}