diff --git a/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/ConnectionOriented/Tcp/TcpTransportTests.cs b/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/ConnectionOriented/Tcp/TcpTransportTests.cs index 1b2c9663e..487fd4c70 100644 --- a/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/ConnectionOriented/Tcp/TcpTransportTests.cs +++ b/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/ConnectionOriented/Tcp/TcpTransportTests.cs @@ -307,6 +307,28 @@ static RaftCluster CreateCluster(int port, bool coldStart, IPersistentState wal } } + [Theory] + [InlineData(2L, HeartbeatResult.ReplicatedWithLeaderTerm)] + [InlineData(3L, HeartbeatResult.Replicated)] + public Task EmptyAppendEntriesAfterEntry(long senderTerm, HeartbeatResult expected) + { + return EmptyAppendEntriesAfterEntryCore(CreateCluster, senderTerm, expected); + + static RaftCluster CreateCluster(int port, bool coldStart, IPersistentState wal) + { + var config = new RaftCluster.TcpConfiguration(new IPEndPoint(IPAddress.Loopback, port)) + { + ColdStart = coldStart, + ConfigurationStorage = null, + }; + + return new(config) + { + AuditTrail = wal, + }; + } + } + [Fact] public Task ClusterRecovery() { diff --git a/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/TransportTestSuite.cs b/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/TransportTestSuite.cs index 275a1ec8c..288df0237 100644 --- a/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/TransportTestSuite.cs +++ b/src/DotNext.Tests/Net/Cluster/Consensus/Raft/NetworkTransport/TransportTestSuite.cs @@ -481,6 +481,23 @@ private protected async Task ClusterRecoveryCore(Func clusterFactory, long senderTerm, HeartbeatResult expected) + { + // the follower's last entry is of term 2 + await using var wal = CreateWal(); + Equal(1L, await wal.AppendAsync(new BufferedEntry(2L, false, RandomBytes(16)), TestToken)); + await using var follower = clusterFactory(3398, false, wal); + await follower.StartAsync(TestToken); + + // a heartbeat with no entries after it: the follower's log holds an entry of the sender's term only if that one is + var result = await follower.As().AppendEntriesAsync(Random.Shared.Next(), senderTerm, + new LogEntryProducer(), prevLogIndex: 1L, prevLogTerm: 2L, commitIndex: 1L, stateVersion: 0, TestToken); + Equal(expected, result.Value.Result); + Equal(1L, result.Value.LastIndex); + + await follower.StopAsync(TestToken); + } + private protected async Task LeadershipCore(Func clusterFactory) { // first node - cold start diff --git a/src/cluster/DotNext.Net.Cluster/Net/Cluster/Consensus/Raft/RaftCluster.cs b/src/cluster/DotNext.Net.Cluster/Net/Cluster/Consensus/Raft/RaftCluster.cs index 4be65bb3a..8a42063a1 100644 --- a/src/cluster/DotNext.Net.Cluster/Net/Cluster/Consensus/Raft/RaftCluster.cs +++ b/src/cluster/DotNext.Net.Cluster/Net/Cluster/Consensus/Raft/RaftCluster.cs @@ -714,7 +714,10 @@ protected async ValueTask> AppendEntriesAsync( { Value = new() { - Result = IsReplicatedWithExpectedTerm(entries) ? HeartbeatResult.ReplicatedWithLeaderTerm : HeartbeatResult.Replicated, + // an empty request whose preceding entry is of the sender's term proves the log holds the leader's entries of its term too + Result = IsReplicatedWithExpectedTerm(entries) || (!notEmpty && prevLogTerm == senderTerm) + ? HeartbeatResult.ReplicatedWithLeaderTerm + : HeartbeatResult.Replicated, LastIndex = lastIndex, } };