From 45182c9af520da5ffe3bba798c30ea1ed76ec956 Mon Sep 17 00:00:00 2001 From: Gutemberg Ribeiro Date: Sat, 31 Oct 2026 10:10:42 -0300 Subject: [PATCH 1/3] A follower answers an empty AppendEntries whose preceding entry is of the sender's term with ReplicatedWithLeaderTerm --- .../Net/Cluster/Consensus/Raft/RaftCluster.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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, } }; From ebda16e2aaabdc38a7e9d96eec7af2b7a64bb785 Mon Sep 17 00:00:00 2001 From: Gutemberg Ribeiro Date: Sat, 31 Oct 2026 10:11:35 -0300 Subject: [PATCH 2/3] Test: a follower's answer to an empty AppendEntries after an entry of the sender's term, and of an older one --- .../Tcp/TcpTransportTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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..2e21894f2 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 @@ -7,6 +7,7 @@ namespace DotNext.Net.Cluster.Consensus.Raft.NetworkTransport.ConnectionOriented.Tcp; using Buffers; +using IO.Log; using Membership; using NetworkTransport; using Replication; @@ -307,6 +308,32 @@ static RaftCluster CreateCluster(int port, bool coldStart, IPersistentState wal } } + [Theory] + [InlineData(2L, HeartbeatResult.ReplicatedWithLeaderTerm)] + [InlineData(3L, HeartbeatResult.Replicated)] + public async Task EmptyAppendEntriesAfterEntry(long senderTerm, HeartbeatResult expected) + { + // the follower's last entry is of term 2 + await using var wal = CreateWal(); + Equal(1L, await wal.AppendAsync(new TestLogEntry("SET X = 0") { Term = 2L }, TestToken)); + + var config = new RaftCluster.TcpConfiguration(new IPEndPoint(IPAddress.Loopback, 3398)) + { + ColdStart = false, + ConfigurationStorage = null, + }; + await using var follower = new RaftCluster(config) { AuditTrail = 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); + } + [Fact] public Task ClusterRecovery() { From dcbe1e518c6322f504711fbddf288ec1f811e2e0 Mon Sep 17 00:00:00 2001 From: Gutemberg Ribeiro Date: Sat, 31 Oct 2026 10:35:16 -0300 Subject: [PATCH 3/3] Test: the empty AppendEntries test in the shared transport suite, built by the AOT tests too (TestLogEntry isn't) --- .../Tcp/TcpTransportTests.cs | 31 ++++++++----------- .../NetworkTransport/TransportTestSuite.cs | 17 ++++++++++ 2 files changed, 30 insertions(+), 18 deletions(-) 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 2e21894f2..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 @@ -7,7 +7,6 @@ namespace DotNext.Net.Cluster.Consensus.Raft.NetworkTransport.ConnectionOriented.Tcp; using Buffers; -using IO.Log; using Membership; using NetworkTransport; using Replication; @@ -311,27 +310,23 @@ static RaftCluster CreateCluster(int port, bool coldStart, IPersistentState wal [Theory] [InlineData(2L, HeartbeatResult.ReplicatedWithLeaderTerm)] [InlineData(3L, HeartbeatResult.Replicated)] - public async Task EmptyAppendEntriesAfterEntry(long senderTerm, HeartbeatResult expected) + public Task EmptyAppendEntriesAfterEntry(long senderTerm, HeartbeatResult expected) { - // the follower's last entry is of term 2 - await using var wal = CreateWal(); - Equal(1L, await wal.AppendAsync(new TestLogEntry("SET X = 0") { Term = 2L }, TestToken)); + return EmptyAppendEntriesAfterEntryCore(CreateCluster, senderTerm, expected); - var config = new RaftCluster.TcpConfiguration(new IPEndPoint(IPAddress.Loopback, 3398)) + static RaftCluster CreateCluster(int port, bool coldStart, IPersistentState wal) { - ColdStart = false, - ConfigurationStorage = null, - }; - await using var follower = new RaftCluster(config) { AuditTrail = 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); + var config = new RaftCluster.TcpConfiguration(new IPEndPoint(IPAddress.Loopback, port)) + { + ColdStart = coldStart, + ConfigurationStorage = null, + }; - await follower.StopAsync(TestToken); + return new(config) + { + AuditTrail = wal, + }; + } } [Fact] 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