fix(raft): vote-reply per-peer dedup + stale-term drop (bug-1095/1096)
raft_on_vote_reply double-counted duplicated vote-responses (counter ignored from_idx) and failed to drop stale-term replies, allowing two leaders at the same term (bug-851 split-brain family). Mirror the existing pre-vote per-peer dedup pattern (new vote_replies[] set) and add DropStaleResponse (term < currentTerm). Surfaced by the raft-sim-conformance oracle (task 4.2). 47 raft / 264 overall lib tests pass.
This commit is contained in:
parent
e15bbf8190
commit
b5b7e01b92
3 changed files with 135 additions and 2 deletions
|
|
@ -1545,6 +1545,100 @@ static void test_vote_reply_pending_waiting(void)
|
|||
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_vote_reply_duplicate_grant_not_double_counted (bug-1095, raft.tla
|
||||
* votesGranted-as-SET, idempotent under DuplicateMessage)
|
||||
*
|
||||
* 5-node cluster (quorum=3). Node starts a real election via raft_start_election
|
||||
* (self-vote=1, vote_replies[self]=1). A GRANTED reply from peer 1 → votes=2,
|
||||
* PENDING. The SAME peer-1 grant delivered AGAIN (a legal DuplicateMessage) must
|
||||
* be IGNORED: RAFT_PENDING, votes still 2, NOT leader. Only a grant from a
|
||||
* DISTINCT peer 2 reaches quorum (votes=3 → RAFT_BECAME_LEADER).
|
||||
*
|
||||
* Before the fix the duplicate would push votes to 3 and elect prematurely
|
||||
* (Election Safety violation — a node could win on a single peer's vote).
|
||||
*/
|
||||
static void test_vote_reply_duplicate_grant_not_double_counted(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
reset_mock();
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 5, 0));
|
||||
|
||||
/* Real election start: CANDIDATE, self-vote=1, vote_replies[self]=1. */
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(1, s.votes_granted);
|
||||
|
||||
/* First grant from peer 1 → votes=2, still below quorum=3. */
|
||||
int rc1 = raft_on_vote_reply(&s, 1, 1, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
|
||||
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||||
|
||||
/* DUPLICATE grant from peer 1 → must be ignored (no double-count). */
|
||||
int rcdup = raft_on_vote_reply(&s, 1, 1, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rcdup);
|
||||
TEST_ASSERT_EQUAL_INT(2, s.votes_granted); /* still 2, NOT 3 */
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||||
|
||||
/* DISTINCT peer 2 grant → votes=3 = quorum → leader. */
|
||||
int rc2 = raft_on_vote_reply(&s, 2, 1, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc2);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(3, s.votes_granted);
|
||||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_vote_reply_stale_term_dropped (bug-1096, raft.tla DropStaleResponse —
|
||||
* discard a response whose mterm < currentTerm)
|
||||
*
|
||||
* Candidate at term=2. A vote reply carrying term=1 (< current) must be DROPPED:
|
||||
* RAFT_PENDING, NOT counted toward votes, and NO step-down (the stale reply is
|
||||
* not a higher term, so it must not flip the node to FOLLOWER). The node stays a
|
||||
* CANDIDATE with its vote tally unchanged.
|
||||
*
|
||||
* Before the fix the stale reply was counted (replies_received++ and, if granted,
|
||||
* votes_granted++) — corrupting the current election's quorum accounting.
|
||||
*/
|
||||
static void test_vote_reply_stale_term_dropped(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
reset_mock();
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 5, 0));
|
||||
|
||||
/* Drive to a real election, then bump to term 2 (second election) so a
|
||||
* reply at term 1 is strictly stale. */
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); /* term 1 */
|
||||
s.election_decided = 0;
|
||||
s.role = RAFT_FOLLOWER; /* allow a second start_election */
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); /* term 2 */
|
||||
TEST_ASSERT_EQUAL_UINT64(2, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(1, s.votes_granted);
|
||||
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
|
||||
|
||||
/* Stale GRANTED reply at term 1 (< current term 2) → dropped. */
|
||||
int rc = raft_on_vote_reply(&s, 1, 1, 1);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
|
||||
|
||||
/* Not counted, no step-down, still CANDIDATE at term 2. */
|
||||
TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* unchanged */
|
||||
TEST_ASSERT_EQUAL_INT(0, s.replies_received); /* unchanged */
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||||
TEST_ASSERT_EQUAL_UINT64(2, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||||
}
|
||||
|
||||
/* ---------- leader progress init on becoming leader (task 2.2) ---------- */
|
||||
|
||||
/* Init a node with a non-zero restored log length (initial_log_len). */
|
||||
|
|
@ -7805,6 +7899,8 @@ void test_raft(void)
|
|||
RUN_TEST(test_vote_reply_no_quorum_all_replied_3node);
|
||||
RUN_TEST(test_vote_reply_quorum_5node);
|
||||
RUN_TEST(test_vote_reply_pending_waiting);
|
||||
RUN_TEST(test_vote_reply_duplicate_grant_not_double_counted);
|
||||
RUN_TEST(test_vote_reply_stale_term_dropped);
|
||||
RUN_TEST(test_leader_progress_init_single_node);
|
||||
RUN_TEST(test_leader_progress_init_multi_node);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue