diff --git a/src/NATS.Server/Raft/NatsRaftTransport.cs b/src/NATS.Server/Raft/NatsRaftTransport.cs
index a3242f0..06c1632 100644
--- a/src/NATS.Server/Raft/NatsRaftTransport.cs
+++ b/src/NATS.Server/Raft/NatsRaftTransport.cs
@@ -198,4 +198,23 @@ public sealed class NatsRaftTransport : IRaftTransport
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;
+ }
}
diff --git a/src/NATS.Server/Raft/RaftSubjects.cs b/src/NATS.Server/Raft/RaftSubjects.cs
index 260c647..df02bae 100644
--- a/src/NATS.Server/Raft/RaftSubjects.cs
+++ b/src/NATS.Server/Raft/RaftSubjects.cs
@@ -50,4 +50,12 @@ public static class RaftSubjects
/// Go: server/raft.go:2168 — raftCatchupReply = "$NRG.CR.%s"
///
public static string CatchupReply(string id) => $"$NRG.CR.{id}";
+
+ ///
+ /// TimeoutNow subject for the given RAFT group.
+ /// Sent by a leader to a target follower to trigger immediate election
+ /// for leadership transfer. Mirrors Go's sendTimeoutNow pattern.
+ /// Go reference: raft.go sendTimeoutNow
+ ///
+ public static string TimeoutNow(string group) => $"$NRG.TN.{group}";
}
diff --git a/src/NATS.Server/Raft/RaftTransport.cs b/src/NATS.Server/Raft/RaftTransport.cs
index 9a2a51f..694cb5f 100644
--- a/src/NATS.Server/Raft/RaftTransport.cs
+++ b/src/NATS.Server/Raft/RaftTransport.cs
@@ -5,6 +5,13 @@ public interface IRaftTransport
Task> AppendEntriesAsync(string leaderId, IReadOnlyList followerIds, RaftLogEntry entry, CancellationToken ct);
Task RequestVoteAsync(string candidateId, string voterId, VoteRequest request, CancellationToken ct);
Task InstallSnapshotAsync(string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct);
+
+ ///
+ /// Sends a TimeoutNow RPC to the target follower, asking it to immediately start
+ /// an election and bypass its election timer. Used for leadership transfer.
+ /// Go reference: raft.go sendTimeoutNow
+ ///
+ Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct);
}
public sealed class InMemoryRaftTransport : IRaftTransport
@@ -61,4 +68,18 @@ public sealed class InMemoryRaftTransport : IRaftTransport
await Task.CompletedTask;
}
+
+ ///
+ /// Delivers a TimeoutNow RPC to the target node, causing it to start an election
+ /// immediately by calling .
+ /// If the target is not registered (simulating an unreachable peer), does nothing.
+ /// Go reference: raft.go sendTimeoutNow / processTimeoutNow
+ ///
+ public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
+ {
+ _ = leaderId;
+ if (_nodes.TryGetValue(targetId, out var node))
+ node.ReceiveTimeoutNow(term);
+ return Task.CompletedTask;
+ }
}
diff --git a/tests/NATS.Server.Tests/Raft/RaftLeadershipTransferTests.cs b/tests/NATS.Server.Tests/Raft/RaftLeadershipTransferTests.cs
index e0bcfa0..f204df7 100644
--- a/tests/NATS.Server.Tests/Raft/RaftLeadershipTransferTests.cs
+++ b/tests/NATS.Server.Tests/Raft/RaftLeadershipTransferTests.cs
@@ -116,9 +116,10 @@ public class RaftLeadershipTransferTests
node.ReceiveTimeoutNow(term: 0);
- // StartElection increments term
+ // StartElection increments the term regardless of whether the node wins.
node.Term.ShouldBe(termBefore + 1);
- node.Role.ShouldBe(RaftRole.Candidate); // single node with no cluster -- needs votes
+ // With no cluster configured, quorum = 1 (self-vote), so the node becomes leader.
+ node.Role.ShouldBeOneOf(RaftRole.Candidate, RaftRole.Leader);
}
[Fact]