namespace NATS.Server.Raft; /// /// Routes RAFT RPCs over internal NATS subjects using the $NRG.* subject space. /// /// In Go, RAFT nodes communicate by publishing binary-encoded messages to /// subjects produced by . Each group has dedicated /// subjects for votes, append-entries, proposals, and remove-peer operations, /// with ephemeral reply inboxes for responses. /// /// This transport encodes outbound RPCs using types /// and delegates the actual publish to a caller-supplied action so that the /// transport itself has no dependency on the full NatsServer. /// /// Go reference: golang/nats-server/server/raft.go:2192-2230 (subject setup), /// 2854-2970 (send helpers: sendVoteRequest, sendAppendEntry, etc.) /// public sealed class NatsRaftTransport : IRaftTransport { private readonly InternalClient _client; private readonly string _groupId; /// /// Delegate invoked to publish a binary payload to a NATS subject with an /// optional reply subject. Maps to Go's n.sendq / sendInternalMsg /// pattern. /// Go: server/raft.go:2854 — n.sendq.push(...) /// private readonly Action> _publish; /// /// Initializes the transport for the given RAFT group. /// /// /// The internal client that represents this node's identity within the /// NATS subject namespace. Used to derive account scope. /// /// /// The RAFT group name. Appended to all $NRG.* subjects. /// Go: server/raft.go:2210 — n.vsubj = fmt.Sprintf(raftVoteSubj, n.group) /// /// /// Callback that publishes a message. Signature: (subject, replyTo, payload). /// Callers typically wire this to the server's internal send path. /// public NatsRaftTransport( InternalClient client, string groupId, Action> publish) { ArgumentNullException.ThrowIfNull(client); ArgumentException.ThrowIfNullOrEmpty(groupId); ArgumentNullException.ThrowIfNull(publish); _client = client; _groupId = groupId; _publish = publish; } /// The RAFT group ID this transport is scoped to. public string GroupId => _groupId; /// The internal client associated with this transport. public InternalClient Client => _client; /// /// Sends an AppendEntry to each follower and collects results. /// /// Encodes the entry using and publishes to /// $NRG.AE.{group} with a reply inbox at $NRG.R.{replyId}. /// In a full clustered implementation responses would be awaited via /// subscription; here the transport records one attempt per follower. /// /// Go: server/raft.go:2854-2916 (sendAppendEntry / sendAppendEntryLocked) /// public Task> AppendEntriesAsync( string leaderId, IReadOnlyList followerIds, RaftLogEntry entry, CancellationToken ct) { var appendSubject = RaftSubjects.AppendEntry(_groupId); var replySubject = RaftSubjects.Reply(Guid.NewGuid().ToString("N")[..8]); // Build wire message. Entries carry the command bytes encoded as Normal type. var entryBytes = System.Text.Encoding.UTF8.GetBytes(entry.Command ?? string.Empty); var wire = new RaftAppendEntryWire( LeaderId: leaderId, Term: (ulong)entry.Term, Commit: 0, PrevTerm: 0, PrevIndex: (ulong)(entry.Index - 1), Entries: [new RaftEntryWire(RaftEntryType.Normal, entryBytes)], LeaderTerm: (ulong)entry.Term); var payload = wire.Encode(); _publish(appendSubject, replySubject, payload); // Build results — one entry per follower indicating the publish was dispatched. // Full result tracking (awaiting replies on replySubject) would be layered // above the transport; this matches Go's fire-and-collect pattern where // responses arrive asynchronously on the reply subject. var results = new List(followerIds.Count); foreach (var followerId in followerIds) results.Add(new AppendResult { FollowerId = followerId, Success = true }); return Task.FromResult>(results); } /// /// Sends a VoteRequest to a single voter and returns a . /// /// Encodes the request using and publishes to /// $NRG.V.{group} with a reply inbox at $NRG.R.{replyId}. /// /// Go: server/raft.go:3594-3630 (requestVote / sendVoteRequest) /// public Task RequestVoteAsync( string candidateId, string voterId, VoteRequest request, CancellationToken ct) { var voteSubject = RaftSubjects.Vote(_groupId); var replySubject = RaftSubjects.Reply(Guid.NewGuid().ToString("N")[..8]); var wire = new RaftVoteRequestWire( Term: (ulong)request.Term, LastTerm: 0, LastIndex: 0, CandidateId: string.IsNullOrEmpty(request.CandidateId) ? candidateId : request.CandidateId); var payload = wire.Encode(); _publish(voteSubject, replySubject, payload); // A full async round-trip would subscribe to replySubject and await // a RaftVoteResponseWire reply. The transport layer records the dispatch; // callers compose the awaiting layer on top (matches Go's vote channel). return Task.FromResult(new VoteResponse { Granted = false }); } /// /// Sends a snapshot to a follower for installation. /// /// Publishes snapshot data to a catchup reply subject /// $NRG.CR.{id}. In Go, snapshot transfer happens over a dedicated /// catchup inbox negotiated out-of-band. /// /// Go: server/raft.go:3247 (buildSnapshotAppendEntry), /// raft.go:2168 — raftCatchupReply = "$NRG.CR.%s" /// public Task InstallSnapshotAsync( string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct) { var catchupSubject = RaftSubjects.CatchupReply(Guid.NewGuid().ToString("N")[..8]); // Encode snapshot as an AppendEntry carrying an OldSnapshot entry. var wire = new RaftAppendEntryWire( LeaderId: leaderId, Term: (ulong)snapshot.LastIncludedTerm, Commit: (ulong)snapshot.LastIncludedIndex, PrevTerm: 0, PrevIndex: (ulong)(snapshot.LastIncludedIndex - 1), Entries: [new RaftEntryWire(RaftEntryType.OldSnapshot, snapshot.Data)]); var payload = wire.Encode(); _publish(catchupSubject, null, payload); return Task.CompletedTask; } /// /// Forwards a proposal to the current leader. /// /// Publishes raw entry bytes to $NRG.P.{group}. /// /// Go: server/raft.go:949 — ForwardProposal → n.sendq.push to n.psubj /// public void ForwardProposal(ReadOnlyMemory entry) { var proposalSubject = RaftSubjects.Proposal(_groupId); _publish(proposalSubject, null, entry); } /// /// Sends a remove-peer proposal to the group leader. /// /// Publishes to $NRG.RP.{group}. /// /// Go: server/raft.go:986 — ProposeRemovePeer → n.sendq.push to n.rpsubj /// public void ProposeRemovePeer(string peer) { var removePeerSubject = RaftSubjects.RemovePeer(_groupId); var payload = System.Text.Encoding.UTF8.GetBytes(peer); _publish(removePeerSubject, null, payload); } /// /// Sends a TimeoutNow RPC to the target follower, asking it to immediately /// start an election to facilitate leadership transfer. /// /// Publishes a -encoded payload to /// $NRG.TN.{group}. The target node's message handler decodes /// it and calls . /// /// Go reference: raft.go sendTimeoutNow /// public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct) { _ = targetId; var subject = RaftSubjects.TimeoutNow(_groupId); var wire = new RaftTimeoutNowWire(Term: term, LeaderId: leaderId); _publish(subject, null, wire.Encode()); return Task.CompletedTask; } /// /// 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 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. /// public Task SendHeartbeatAsync( string leaderId, IReadOnlyList followerIds, int term, Action 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; } }