7814 lines
287 KiB
C
7814 lines
287 KiB
C
#include <errno.h>
|
||
#include <stdint.h>
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
|
||
#include <unity/unity.h>
|
||
|
||
#include "../raft/raft.h"
|
||
|
||
/* ---------- stub callbacks ---------- */
|
||
|
||
static int stub_send_vote_request(void *ctx, int peer_idx, uint64_t term)
|
||
{
|
||
(void)ctx; (void)peer_idx; (void)term;
|
||
return 0;
|
||
}
|
||
|
||
static void stub_become_leader(void *ctx, uint64_t term)
|
||
{
|
||
(void)ctx; (void)term;
|
||
}
|
||
|
||
static void stub_become_follower(void *ctx, uint64_t term, int leader_idx)
|
||
{
|
||
(void)ctx; (void)term; (void)leader_idx;
|
||
}
|
||
|
||
static int stub_persist(void *ctx, uint64_t term, int voted_for)
|
||
{
|
||
(void)ctx; (void)term; (void)voted_for;
|
||
return 0;
|
||
}
|
||
|
||
static void stub_schedule_timeout(void *ctx, uint32_t delay_ms)
|
||
{
|
||
(void)ctx; (void)delay_ms;
|
||
}
|
||
|
||
static void stub_cancel_timeout(void *ctx)
|
||
{
|
||
(void)ctx;
|
||
}
|
||
|
||
static void stub_start_election_after_delay(void *ctx, uint32_t delay_ms)
|
||
{
|
||
(void)ctx; (void)delay_ms;
|
||
}
|
||
|
||
/* ---- log-replication callback stubs (raft-log-replication) ---- */
|
||
|
||
static int stub_persist_entry(void *ctx, uint64_t index, uint64_t term,
|
||
const void *entry, size_t len)
|
||
{
|
||
(void)ctx; (void)index; (void)term; (void)entry; (void)len;
|
||
return 0;
|
||
}
|
||
|
||
static uint64_t stub_get_entry_term(void *ctx, uint64_t index)
|
||
{
|
||
(void)ctx; (void)index;
|
||
return 0;
|
||
}
|
||
|
||
static int stub_send_append_entries(void *ctx, int peer_idx,
|
||
uint64_t prev_log_index,
|
||
uint64_t prev_log_term,
|
||
uint64_t leader_commit, int n_entries)
|
||
{
|
||
(void)ctx; (void)peer_idx; (void)prev_log_index; (void)prev_log_term;
|
||
(void)leader_commit; (void)n_entries;
|
||
return 0;
|
||
}
|
||
|
||
static int stub_truncate_from(void *ctx, uint64_t index)
|
||
{
|
||
(void)ctx; (void)index;
|
||
return 0;
|
||
}
|
||
|
||
/* ---- commit-advance callback stub (raft-commit-safety) ---- */
|
||
|
||
static void stub_commit_advanced(void *ctx, uint64_t commit_index)
|
||
{
|
||
(void)ctx; (void)commit_index;
|
||
}
|
||
|
||
/* Build a fully-populated callbacks struct */
|
||
static raft_callbacks_t make_full_cbs(void)
|
||
{
|
||
raft_callbacks_t cbs;
|
||
memset(&cbs, 0, sizeof(cbs));
|
||
cbs.on_send_vote_request = stub_send_vote_request;
|
||
cbs.on_become_leader = stub_become_leader;
|
||
cbs.on_become_follower = stub_become_follower;
|
||
cbs.on_persist = stub_persist;
|
||
cbs.on_schedule_timeout = stub_schedule_timeout;
|
||
cbs.on_cancel_timeout = stub_cancel_timeout;
|
||
cbs.on_start_election_after_delay = stub_start_election_after_delay;
|
||
cbs.on_persist_entry = stub_persist_entry;
|
||
cbs.on_get_entry_term = stub_get_entry_term;
|
||
cbs.on_send_append_entries = stub_send_append_entries;
|
||
cbs.on_truncate_from = stub_truncate_from;
|
||
cbs.on_commit_advanced = stub_commit_advanced;
|
||
/* on_vote_eligible and on_trace left NULL (optional) */
|
||
return cbs;
|
||
}
|
||
|
||
/* ---------- mock infrastructure for raft_start_election tests ---------- */
|
||
|
||
/* Per-test counters — reset by each test before calling start_election */
|
||
typedef struct {
|
||
int send_vote_request_count;
|
||
int last_send_peer_idx;
|
||
uint64_t last_send_term;
|
||
/* track each peer index sent (up to 8 for test purposes) */
|
||
int sent_peers[8];
|
||
|
||
int schedule_timeout_count;
|
||
uint32_t last_schedule_delay;
|
||
|
||
int persist_count;
|
||
uint64_t last_persist_term;
|
||
int last_persist_voted_for;
|
||
int persist_return_value; /* configurable: 0=success, non-zero=fail */
|
||
|
||
int become_leader_count;
|
||
uint64_t last_become_leader_term;
|
||
|
||
/* on_vote_eligible mock */
|
||
int vote_eligible_return_value; /* configurable: 1=grant, 0=deny; default 1 */
|
||
int vote_eligible_call_count;
|
||
|
||
/* on_get_entry_term mock (raft-commit-safety freshness, task 4.1/5.1).
|
||
* The voter's local last-log term is returned for index == get_entry_term_log_len;
|
||
* any other index returns 0 (absent). Default 0 keeps empty-log scenarios at the
|
||
* pre-existing local_last_log_term == 0. */
|
||
uint64_t local_last_log_term; /* term reported at index == get_entry_term_log_len */
|
||
uint64_t get_entry_term_log_len; /* the index treated as the local last log */
|
||
|
||
/* on_trace mock */
|
||
int trace_call_count;
|
||
char last_trace_event[64];
|
||
|
||
/* on_become_follower mock */
|
||
int become_follower_count;
|
||
uint64_t last_become_follower_term;
|
||
int last_become_follower_leader_idx;
|
||
|
||
/* on_cancel_timeout mock */
|
||
int cancel_timeout_count;
|
||
|
||
/* on_start_election_after_delay mock (task 3.5) */
|
||
int start_election_after_delay_count;
|
||
uint32_t last_start_election_delay;
|
||
|
||
/* on_send_pre_vote_request mock (task 2.2) */
|
||
int send_pre_vote_request_count;
|
||
int last_pre_vote_peer_idx;
|
||
uint64_t last_pre_vote_term;
|
||
/* per-peer prospective term recorded by index (up to 8) */
|
||
uint64_t pre_vote_sent_term[8]; /* term sent to peer i; 0 = not sent */
|
||
int pre_vote_sent_peers[8]; /* ordered list of peer indices sent to */
|
||
} mock_cbs_state_t;
|
||
|
||
static mock_cbs_state_t g_mock;
|
||
|
||
static void reset_mock(void)
|
||
{
|
||
memset(&g_mock, 0, sizeof(g_mock));
|
||
/* Defaults: persist succeeds, vote_eligible grants */
|
||
g_mock.persist_return_value = 0;
|
||
g_mock.vote_eligible_return_value = 1;
|
||
}
|
||
|
||
static int mock_send_vote_request(void *ctx, int peer_idx, uint64_t term)
|
||
{
|
||
(void)ctx;
|
||
if (g_mock.send_vote_request_count < 8)
|
||
g_mock.sent_peers[g_mock.send_vote_request_count] = peer_idx;
|
||
g_mock.last_send_peer_idx = peer_idx;
|
||
g_mock.last_send_term = term;
|
||
g_mock.send_vote_request_count++;
|
||
return 0;
|
||
}
|
||
|
||
static void mock_become_leader(void *ctx, uint64_t term)
|
||
{
|
||
(void)ctx;
|
||
g_mock.become_leader_count++;
|
||
g_mock.last_become_leader_term = term;
|
||
}
|
||
|
||
static void mock_become_follower(void *ctx, uint64_t term, int leader_idx)
|
||
{
|
||
(void)ctx;
|
||
g_mock.become_follower_count++;
|
||
g_mock.last_become_follower_term = term;
|
||
g_mock.last_become_follower_leader_idx = leader_idx;
|
||
}
|
||
|
||
static void mock_cancel_timeout(void *ctx)
|
||
{
|
||
(void)ctx;
|
||
g_mock.cancel_timeout_count++;
|
||
}
|
||
|
||
static void mock_start_election_after_delay(void *ctx, uint32_t delay_ms)
|
||
{
|
||
(void)ctx;
|
||
g_mock.start_election_after_delay_count++;
|
||
g_mock.last_start_election_delay = delay_ms;
|
||
}
|
||
|
||
static int mock_send_pre_vote_request(void *ctx, int peer_idx,
|
||
uint64_t prospective_term)
|
||
{
|
||
(void)ctx;
|
||
if (g_mock.send_pre_vote_request_count < 8)
|
||
g_mock.pre_vote_sent_peers[g_mock.send_pre_vote_request_count] = peer_idx;
|
||
if (peer_idx >= 0 && peer_idx < 8)
|
||
g_mock.pre_vote_sent_term[peer_idx] = prospective_term;
|
||
g_mock.last_pre_vote_peer_idx = peer_idx;
|
||
g_mock.last_pre_vote_term = prospective_term;
|
||
g_mock.send_pre_vote_request_count++;
|
||
return 0;
|
||
}
|
||
|
||
static int mock_persist(void *ctx, uint64_t term, int voted_for)
|
||
{
|
||
(void)ctx;
|
||
g_mock.persist_count++;
|
||
g_mock.last_persist_term = term;
|
||
g_mock.last_persist_voted_for = voted_for;
|
||
return g_mock.persist_return_value;
|
||
}
|
||
|
||
static void mock_schedule_timeout(void *ctx, uint32_t delay_ms)
|
||
{
|
||
(void)ctx;
|
||
g_mock.schedule_timeout_count++;
|
||
g_mock.last_schedule_delay = delay_ms;
|
||
}
|
||
|
||
static int mock_vote_eligible(void *ctx, int from_idx, uint64_t term)
|
||
{
|
||
(void)ctx; (void)from_idx; (void)term;
|
||
g_mock.vote_eligible_call_count++;
|
||
return g_mock.vote_eligible_return_value;
|
||
}
|
||
|
||
/* Configurable local-log term reporter for freshness tests (task 4.1/5.1).
|
||
* Returns g_mock.local_last_log_term for the configured last-log index, 0
|
||
* otherwise. Defaults (both 0) reproduce the previous always-0 behaviour, so
|
||
* empty-log call sites are unaffected. */
|
||
static uint64_t mock_get_entry_term(void *ctx, uint64_t index)
|
||
{
|
||
(void)ctx;
|
||
if (g_mock.get_entry_term_log_len != 0 &&
|
||
index == g_mock.get_entry_term_log_len)
|
||
return g_mock.local_last_log_term;
|
||
return 0;
|
||
}
|
||
|
||
static void mock_trace(void *ctx, const char *event, const char *detail)
|
||
{
|
||
(void)ctx; (void)detail;
|
||
g_mock.trace_call_count++;
|
||
if (event) {
|
||
strncpy(g_mock.last_trace_event, event,
|
||
sizeof(g_mock.last_trace_event) - 1);
|
||
g_mock.last_trace_event[sizeof(g_mock.last_trace_event) - 1] = '\0';
|
||
}
|
||
}
|
||
|
||
/* Build callbacks that record invocations in g_mock */
|
||
static raft_callbacks_t make_mock_cbs(void)
|
||
{
|
||
raft_callbacks_t cbs;
|
||
memset(&cbs, 0, sizeof(cbs));
|
||
cbs.on_send_vote_request = mock_send_vote_request;
|
||
cbs.on_become_leader = mock_become_leader;
|
||
cbs.on_become_follower = mock_become_follower;
|
||
cbs.on_persist = mock_persist;
|
||
cbs.on_schedule_timeout = mock_schedule_timeout;
|
||
cbs.on_cancel_timeout = mock_cancel_timeout;
|
||
cbs.on_start_election_after_delay = mock_start_election_after_delay;
|
||
cbs.on_send_pre_vote_request = mock_send_pre_vote_request;
|
||
/* Log-replication callbacks: stubs (task 2.1 only needs them non-NULL for
|
||
* raft_init validation; behaviour is exercised by later tasks). */
|
||
cbs.on_persist_entry = stub_persist_entry;
|
||
cbs.on_get_entry_term = stub_get_entry_term;
|
||
cbs.on_send_append_entries = stub_send_append_entries;
|
||
cbs.on_truncate_from = stub_truncate_from;
|
||
cbs.on_commit_advanced = stub_commit_advanced;
|
||
/* on_vote_eligible and on_trace left NULL — tests that need them set explicitly */
|
||
return cbs;
|
||
}
|
||
|
||
/* ---------- helpers ---------- */
|
||
|
||
/* Initialize a raft_state_t with mock callbacks; n_peers and self_idx configurable */
|
||
static int init_node(raft_state_t *s, raft_callbacks_t *cbs, int n_peers, int self_idx)
|
||
{
|
||
raft_config_t cfg;
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = n_peers;
|
||
cfg.self_idx = self_idx;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.cbs = cbs;
|
||
return raft_init(s, &cfg);
|
||
}
|
||
|
||
/* ---------- raft_init tests ---------- */
|
||
|
||
/* raft_init with NULL required callback returns -EINVAL and does not modify s */
|
||
static void test_raft_init_null_send_vote_request_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
/* canary: fill s with a non-zero sentinel so we can verify it's untouched */
|
||
memset(&s, 0xAB, sizeof(s));
|
||
|
||
cbs.on_send_vote_request = NULL; /* knock out a required callback */
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
cfg.ctx = NULL;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
||
|
||
/* s must be completely unchanged (canary check — spot-check first byte) */
|
||
{
|
||
unsigned char buf[sizeof(s)];
|
||
memset(buf, 0xAB, sizeof(buf));
|
||
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
|
||
}
|
||
}
|
||
|
||
static void test_raft_init_null_become_leader_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_become_leader = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_become_follower_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_become_follower = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_persist_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_persist = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_schedule_timeout_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_schedule_timeout = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_cancel_timeout_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_cancel_timeout = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_start_election_after_delay_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_start_election_after_delay = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
/* ---- log-replication required callbacks (Req 7.1–7.4): each NULL → -EINVAL ---- */
|
||
|
||
static void test_raft_init_null_persist_entry_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_persist_entry = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_get_entry_term_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_get_entry_term = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_send_append_entries_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_send_append_entries = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
static void test_raft_init_null_truncate_from_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_truncate_from = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
/* All four log callbacks present → succeeds; initial_log_len restored to log_len (Req 1.1) */
|
||
static void test_raft_init_log_callbacks_present_succeeds(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.initial_log_len = 5; /* WAL length restored from disk */
|
||
cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.log_len);
|
||
}
|
||
|
||
/* ---- commit-advance required callback + commit_index restore (raft-commit-safety
|
||
* task 2.1, Req 2.5, 1.1) ---- */
|
||
|
||
/* on_commit_advanced NULL → -EINVAL (Req 2.5), like the other required callbacks */
|
||
static void test_raft_init_null_commit_advanced_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_commit_advanced = NULL; /* knock out the new required callback */
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
}
|
||
|
||
/* on_commit_advanced present → succeeds (Req 2.5) */
|
||
static void test_raft_init_commit_advanced_present_succeeds(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
}
|
||
|
||
/* commit_index restored from initial_commit_index at init (Req 1.1) */
|
||
static void test_raft_init_restores_commit_index(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0xAB, sizeof(s)); /* poison: prove init writes the field */
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.initial_commit_index = 7; /* committed LSN restored from disk */
|
||
cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
TEST_ASSERT_EQUAL_UINT64(7, s.commit_index);
|
||
}
|
||
|
||
/* default commit_index is 0 on first boot (Req 1.1, default 0) */
|
||
static void test_raft_init_commit_index_default_zero(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0xAB, sizeof(s)); /* poison */
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs; /* initial_commit_index left 0 */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
|
||
}
|
||
|
||
/* n_peers even (>1) → -EINVAL: even-sized clusters have no fault-tolerance
|
||
* advantage over the next-smaller odd cluster; reject to prevent misconfiguration. */
|
||
static void test_raft_init_even_peers_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0xAB, sizeof(s));
|
||
|
||
/* n_peers=2: even, >1 → must fail */
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 2;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
|
||
/* n_peers=4: even, >1 → must fail */
|
||
cfg.n_peers = 4;
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
|
||
/* n_peers=1: single-node is always valid (odd by convention) */
|
||
cfg.n_peers = 1;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
/* n_peers=3: odd, >1 → must succeed */
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
/* s must NOT be unchanged after valid inits above */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
}
|
||
|
||
/* n_peers > RAFT_MAX_PEERS → -EINVAL: the fixed-size next_index[]/match_index[]/
|
||
* pre_vote_replies[] arrays are indexed by peer, so an oversize cluster must be
|
||
* rejected before any leader-progress/pre-vote loop can OOB-write. */
|
||
static void test_raft_init_oversize_peers_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0xAB, sizeof(s));
|
||
|
||
/* n_peers = RAFT_MAX_PEERS + 1 → must fail (would OOB next_index[]) */
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = RAFT_MAX_PEERS + 1;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
|
||
/* s must be untouched on rejection (canary) */
|
||
{
|
||
unsigned char buf[sizeof(s)];
|
||
memset(buf, 0xAB, sizeof(buf));
|
||
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
|
||
}
|
||
|
||
/* n_peers = RAFT_MAX_PEERS (odd? RAFT_MAX_PEERS=8 is even → also rejected by
|
||
* the even-peers rule, so use the largest valid odd size at the bound) */
|
||
cfg.n_peers = RAFT_MAX_PEERS - 1; /* 7: odd, within bound → must succeed */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
}
|
||
|
||
/* n_peers == 0 → -EINVAL */
|
||
static void test_raft_init_zero_peers_returns_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0xAB, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 0;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
|
||
|
||
/* s must be unchanged */
|
||
unsigned char buf[sizeof(s)];
|
||
memset(buf, 0xAB, sizeof(buf));
|
||
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
|
||
}
|
||
|
||
/* Successful init: check all fields */
|
||
static void test_raft_init_success_fields(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 1;
|
||
cfg.election_timeout_ms = 1500;
|
||
cfg.my_priority = 2;
|
||
cfg.initial_term = 7;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
cfg.ctx = (void *)0xDEADBEEF;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(3, s.n_peers);
|
||
TEST_ASSERT_EQUAL_INT(1, s.self_idx);
|
||
TEST_ASSERT_EQUAL_UINT32(1500, s.election_timeout_ms);
|
||
TEST_ASSERT_EQUAL_INT(2, s.my_priority);
|
||
TEST_ASSERT_EQUAL_UINT64(7, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(0, s.votes_granted);
|
||
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
|
||
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
|
||
|
||
/* cbs/ctx must be stored in raft_state_t for the state machine to fire callbacks */
|
||
TEST_ASSERT_EQUAL_PTR(&cbs, s.cbs);
|
||
TEST_ASSERT_EQUAL_PTR((void *)0xDEADBEEF, s.ctx);
|
||
}
|
||
|
||
/* election_timeout_ms == 0 → default 2000 */
|
||
static void test_raft_init_default_election_timeout(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.election_timeout_ms = 0; /* trigger default */
|
||
cfg.cbs = &cbs;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
TEST_ASSERT_EQUAL_UINT32(2000, s.election_timeout_ms);
|
||
}
|
||
|
||
/* optional callbacks (on_vote_eligible, on_trace) may be NULL — must succeed */
|
||
static void test_raft_init_optional_callbacks_null_succeeds(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
cbs.on_vote_eligible = NULL;
|
||
cbs.on_trace = NULL;
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
}
|
||
|
||
/* ---------- raft_start_election tests (task 3.2) ---------- */
|
||
|
||
/*
|
||
* test_start_election_from_follower_3node
|
||
*
|
||
* 3-node cluster (self=0). raft_start_election transitions to CANDIDATE,
|
||
* increments term, records self-vote, fires on_persist(new_term, self_idx),
|
||
* fires on_send_vote_request for each peer != self_idx (peers 1 and 2),
|
||
* fires on_schedule_timeout once, returns RAFT_OK.
|
||
*/
|
||
static void test_start_election_from_follower_3node(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.term);
|
||
|
||
int rc = raft_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.term);
|
||
TEST_ASSERT_EQUAL_INT(0, s.self_idx);
|
||
TEST_ASSERT_EQUAL_INT(0, s.voted_for); /* voted_for == self_idx */
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.voted_for_term); /* voted_for_term == new term */
|
||
TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* self-vote counted */
|
||
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
|
||
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
|
||
|
||
/* on_persist must fire once with (new_term=1, self_idx=0) */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for);
|
||
|
||
/* on_send_vote_request must fire for peers 1 and 2 (not self=0) */
|
||
TEST_ASSERT_EQUAL_INT(2, g_mock.send_vote_request_count);
|
||
/* Both sends must use the new term */
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_send_term);
|
||
/* Verify neither sent peer is self_idx=0 */
|
||
{
|
||
int i;
|
||
for (i = 0; i < g_mock.send_vote_request_count; i++) {
|
||
TEST_ASSERT_NOT_EQUAL(0, g_mock.sent_peers[i]);
|
||
}
|
||
}
|
||
|
||
/* on_schedule_timeout must fire exactly once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
|
||
TEST_ASSERT_EQUAL_UINT32(1000, g_mock.last_schedule_delay);
|
||
|
||
/* on_become_leader must NOT fire (multi-node path) */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_start_election_single_node
|
||
*
|
||
* 1-node cluster (self=0). raft_start_election shortcircuits to LEADER,
|
||
* fires on_persist then on_become_leader, returns RAFT_ELECTED_IMMEDIATELY.
|
||
* No on_send_vote_request or on_schedule_timeout calls.
|
||
*/
|
||
static void test_start_election_single_node(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 1, 0));
|
||
|
||
int rc = raft_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.term);
|
||
TEST_ASSERT_EQUAL_INT(0, s.voted_for); /* self-vote */
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_persist fires once with (term=1, self_idx=0) */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for);
|
||
|
||
/* on_become_leader fires once with term=1 */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_leader_term);
|
||
|
||
/* on_send_vote_request must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
|
||
|
||
/* on_schedule_timeout must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
|
||
}
|
||
|
||
/*
|
||
* test_start_election_already_candidate
|
||
*
|
||
* Call raft_start_election once (become CANDIDATE), then call again.
|
||
* Second call must return -EALREADY and leave state completely unchanged.
|
||
*/
|
||
static void test_start_election_already_candidate(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 1));
|
||
|
||
/* First election — becomes CANDIDATE */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
uint64_t term_after_first = s.term;
|
||
|
||
/* Snapshot the state before the second call */
|
||
int saved_voted_for = s.voted_for;
|
||
int saved_votes_granted = s.votes_granted;
|
||
int saved_replies_recvd = s.replies_received;
|
||
int saved_election_dec = s.election_decided;
|
||
|
||
reset_mock();
|
||
|
||
/* Second call must be rejected */
|
||
int rc = raft_start_election(&s);
|
||
TEST_ASSERT_EQUAL_INT(-EALREADY, rc);
|
||
|
||
/* State must be unchanged */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(term_after_first, s.term);
|
||
TEST_ASSERT_EQUAL_INT(saved_voted_for, s.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(saved_votes_granted, s.votes_granted);
|
||
TEST_ASSERT_EQUAL_INT(saved_replies_recvd, s.replies_received);
|
||
TEST_ASSERT_EQUAL_INT(saved_election_dec, s.election_decided);
|
||
|
||
/* No callbacks must have fired on the second call */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_start_election_already_leader
|
||
*
|
||
* Manually set role=RAFT_LEADER, then call raft_start_election.
|
||
* Must return -EALREADY without modifying any state or firing any callback.
|
||
*/
|
||
static void test_start_election_already_leader(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 2));
|
||
|
||
/* Manually elevate to LEADER */
|
||
s.role = RAFT_LEADER;
|
||
s.term = 5;
|
||
|
||
int rc = raft_start_election(&s);
|
||
TEST_ASSERT_EQUAL_INT(-EALREADY, rc);
|
||
|
||
/* All state must be unchanged */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
|
||
/* No callbacks must fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
}
|
||
|
||
/* ---------- raft_on_vote_request tests (task 3.3) ---------- */
|
||
|
||
/*
|
||
* Helper: init a 3-node node with mock callbacks (including trace).
|
||
* term, role, voted_for can be set after init.
|
||
*/
|
||
static int init_vote_node(raft_state_t *s, raft_callbacks_t *cbs,
|
||
int n_peers, int self_idx, uint64_t initial_term)
|
||
{
|
||
raft_config_t cfg;
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = n_peers;
|
||
cfg.self_idx = self_idx;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_term = initial_term;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = cbs;
|
||
return raft_init(s, &cfg);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_stale_term
|
||
*
|
||
* Node at term=5, receives request with term=3.
|
||
* Expected: RAFT_DENY, *reply_term=5, state unchanged,
|
||
* on_trace fired with "vote_denied_stale_term".
|
||
*/
|
||
static void test_vote_req_stale_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
/* Confirm starting state */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* State must be unchanged */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
|
||
/* on_trace must fire with "vote_denied_stale_term" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("vote_denied_stale_term", g_mock.last_trace_event);
|
||
|
||
/* on_persist must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_higher_term_updates_state
|
||
*
|
||
* Node at term=2, receives request with term=5 (higher).
|
||
* Expected: s->term updated to 5, voted_for cleared to -1, role=FOLLOWER.
|
||
* Since voted_for is unset and persist succeeds: RAFT_GRANT.
|
||
* reply_term = 5.
|
||
*/
|
||
static void test_vote_req_higher_term_updates_state(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 2));
|
||
/* Simulate node was CANDIDATE in term=2 */
|
||
s.role = RAFT_CANDIDATE;
|
||
s.voted_for = 0; /* voted for self */
|
||
s.voted_for_term = 2;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
/* Term upgrade must happen: term updated, role stepped down */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
/* Then grant because voted_for is cleared and persist succeeds */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* on_persist must fire once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(5, g_mock.last_persist_term);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.last_persist_voted_for);
|
||
|
||
/* voted_for set on grant */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_same_term_already_voted_different
|
||
*
|
||
* Node at term=5, voted_for=1, voted_for_term=5.
|
||
* Receives request from idx=2 at term=5.
|
||
* Expected: RAFT_DENY, "vote_denied_already_voted" trace.
|
||
*/
|
||
static void test_vote_req_same_term_already_voted_different(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 5;
|
||
|
||
int rc = raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* State: voted_for still 1 (not 2), term unchanged */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
|
||
/* on_trace fires with already_voted */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("vote_denied_already_voted", g_mock.last_trace_event);
|
||
|
||
/* on_persist must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_same_term_idempotent_same_candidate
|
||
*
|
||
* Node at term=5, voted_for=1, voted_for_term=5.
|
||
* Receives request from idx=1 at term=5 (same candidate, same term).
|
||
* Expected: idempotent re-grant → RAFT_GRANT, voted_for still 1.
|
||
*/
|
||
static void test_vote_req_same_term_idempotent_same_candidate(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 5;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* voted_for still 1 */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
|
||
/* on_persist must fire once for the re-grant */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_leader_denies
|
||
*
|
||
* Node is RAFT_LEADER at term=5, receives request at term=5 (same term, no upgrade).
|
||
* Expected: RAFT_DENY ("vote_denied_role" trace), role stays LEADER.
|
||
*/
|
||
static void test_vote_req_leader_denies(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.role = RAFT_LEADER;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* Role gate fires: still LEADER (term==request term, no upgrade) */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
|
||
/* on_trace fires with "vote_denied_role" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("vote_denied_role", g_mock.last_trace_event);
|
||
|
||
/* on_persist must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_on_vote_eligible_denies
|
||
*
|
||
* on_vote_eligible returns 0 → RAFT_DENY, voted_for unchanged,
|
||
* "vote_denied_freshness" trace fires.
|
||
*/
|
||
static void test_vote_req_on_vote_eligible_denies(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 0; /* deny */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* voted_for must be unchanged */
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
|
||
/* on_vote_eligible must have been called */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
|
||
/* on_trace fires with "vote_denied_freshness" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("vote_denied_freshness", g_mock.last_trace_event);
|
||
|
||
/* on_persist must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_on_vote_eligible_grants
|
||
*
|
||
* on_vote_eligible returns 1 and persist succeeds → RAFT_GRANT, voted_for set.
|
||
*/
|
||
static void test_vote_req_on_vote_eligible_grants(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1; /* grant */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* voted_for must be set */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
|
||
/* on_vote_eligible called once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
|
||
/* on_persist called once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
|
||
/* on_trace fires "vote_granted" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("vote_granted", g_mock.last_trace_event);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_on_vote_eligible_null
|
||
*
|
||
* on_vote_eligible is NULL → treated as eligible, grant if persist ok.
|
||
*/
|
||
static void test_vote_req_on_vote_eligible_null(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
/* on_vote_eligible left NULL in make_mock_cbs */
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* voted_for must be set */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
|
||
/* on_persist called once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_persist_fails
|
||
*
|
||
* on_persist returns non-zero → RAFT_DENY, voted_for unchanged.
|
||
*/
|
||
static void test_vote_req_persist_fails(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
g_mock.persist_return_value = -1; /* fail */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* voted_for must NOT be updated */
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
|
||
/* on_persist called once (it ran, but failed) */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_reply_term_always_set
|
||
*
|
||
* Verify that *reply_term == s->term on every return path by testing
|
||
* several scenarios and checking reply_term each time.
|
||
*/
|
||
static void test_vote_req_reply_term_always_set(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term;
|
||
|
||
/* Path 1: stale term deny */
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 7));
|
||
reply_term = 0;
|
||
raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal current term=7 */
|
||
|
||
/* Path 2: term upgrade + grant */
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
|
||
reply_term = 0;
|
||
raft_on_vote_request(&s, 1, 7, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal upgraded term=7 */
|
||
|
||
/* Path 3: already voted for different — deny */
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.voted_for = 1; s.voted_for_term = 5;
|
||
reply_term = 0;
|
||
raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
|
||
|
||
/* Path 4: persist fails — deny */
|
||
g_mock.persist_return_value = -1;
|
||
reset_mock();
|
||
g_mock.persist_return_value = -1;
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
reply_term = 0;
|
||
raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
|
||
}
|
||
|
||
/* ---------- raft_on_vote_reply tests (task 3.4) ---------- */
|
||
|
||
/*
|
||
* test_vote_reply_higher_term_steps_down
|
||
*
|
||
* Node is CANDIDATE at term=2. Receives reply with term=5 (higher).
|
||
* Expected: RAFT_BECAME_FOLLOWER, role=FOLLOWER, term=5, election_decided=1,
|
||
* on_become_follower fired with (5, -1), on_cancel_timeout fired once.
|
||
*/
|
||
static void test_vote_reply_higher_term_steps_down(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, 3, 0));
|
||
/* Manually put node into CANDIDATE state at term=2 */
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 2;
|
||
s.votes_granted = 1;
|
||
s.replies_received = 0;
|
||
s.election_decided = 0;
|
||
|
||
int rc = raft_on_vote_reply(&s, 1, 0, 5);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_follower must fire once with (term=5, leader_idx=-1) */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_UINT64(5, g_mock.last_become_follower_term);
|
||
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
|
||
|
||
/* on_cancel_timeout must fire once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
|
||
|
||
/* on_become_leader must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_reply_noop_after_decided
|
||
*
|
||
* election_decided=1 already set. Call raft_on_vote_reply.
|
||
* Expected: RAFT_PENDING, no callbacks fire.
|
||
*/
|
||
static void test_vote_reply_noop_after_decided(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, 3, 0));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 2;
|
||
s.votes_granted = 1;
|
||
s.replies_received = 0;
|
||
s.election_decided = 1; /* already decided */
|
||
|
||
int rc = raft_on_vote_reply(&s, 1, 1, 2);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
|
||
|
||
/* No callbacks must fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_reply_quorum_3node_1grant
|
||
*
|
||
* 3-node cluster (quorum=2). Node is CANDIDATE with votes_granted=1 (self-vote).
|
||
* One peer grant arrives → votes_granted=2 >= quorum=2.
|
||
* Expected: RAFT_BECAME_LEADER, role=LEADER, election_decided=1,
|
||
* on_become_leader fired, on_cancel_timeout fired.
|
||
*/
|
||
static void test_vote_reply_quorum_3node_1grant(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, 3, 0));
|
||
/* Simulate state after raft_start_election: CANDIDATE, self-vote counted */
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 1;
|
||
s.votes_granted = 1;
|
||
s.replies_received = 0;
|
||
s.election_decided = 0;
|
||
|
||
/* One peer grants */
|
||
int rc = raft_on_vote_reply(&s, 1, 1, 1);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
|
||
|
||
/* on_become_leader must fire once with the current term */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_leader_term);
|
||
|
||
/* on_cancel_timeout must fire once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
|
||
|
||
/* on_become_follower must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_reply_no_quorum_all_replied_3node
|
||
*
|
||
* 3-node cluster. Node starts election (votes_granted=1, replies_received=0).
|
||
* Both peers deny. After second deny: replies_received=2 == n_peers-1=2,
|
||
* votes_granted=1 < quorum=2 → RAFT_BECAME_FOLLOWER.
|
||
*/
|
||
static void test_vote_reply_no_quorum_all_replied_3node(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, 3, 0));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 1;
|
||
s.votes_granted = 1;
|
||
s.replies_received = 0;
|
||
s.election_decided = 0;
|
||
|
||
/* First deny: replies_received=1, not yet all replied */
|
||
int rc1 = raft_on_vote_reply(&s, 1, 0, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
|
||
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
|
||
/* Second deny: replies_received=2 == n_peers-1=2 → step down */
|
||
int rc2 = raft_on_vote_reply(&s, 2, 0, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc2);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_follower must fire once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_follower_term);
|
||
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
|
||
|
||
/* on_become_leader must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
|
||
/* on_cancel_timeout must NOT fire for all-replied-no-quorum (only term-upgrade and quorum paths cancel) */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_reply_quorum_5node
|
||
*
|
||
* 5-node cluster (quorum=3). Node starts election (votes_granted=1).
|
||
* Two grants arrive → votes_granted=3 = quorum=3 → RAFT_BECAME_LEADER.
|
||
*/
|
||
static void test_vote_reply_quorum_5node(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));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 1;
|
||
s.votes_granted = 1;
|
||
s.replies_received = 0;
|
||
s.election_decided = 0;
|
||
|
||
/* First grant: votes_granted=2, quorum=3 → still pending */
|
||
int rc1 = raft_on_vote_reply(&s, 1, 1, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
|
||
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
|
||
|
||
/* Second grant: votes_granted=3 = quorum → leader */
|
||
int rc2 = raft_on_vote_reply(&s, 2, 1, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc2);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
TEST_ASSERT_EQUAL_INT(3, s.votes_granted);
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_reply_pending_waiting
|
||
*
|
||
* 3-node cluster. Node starts election (votes_granted=1, replies_received=0).
|
||
* One deny arrives: replies_received=1, n_peers-1=2 → not all replied yet.
|
||
* Expected: RAFT_PENDING, no role change, no callbacks.
|
||
*/
|
||
static void test_vote_reply_pending_waiting(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, 3, 0));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 1;
|
||
s.votes_granted = 1;
|
||
s.replies_received = 0;
|
||
s.election_decided = 0;
|
||
|
||
/* One deny: replies_received=1, n_peers-1=2 → still waiting */
|
||
int rc = raft_on_vote_reply(&s, 1, 0, 1);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
|
||
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
|
||
TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* unchanged */
|
||
|
||
/* No role-change callbacks */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
|
||
}
|
||
|
||
/* ---------- leader progress init on becoming leader (task 2.2) ---------- */
|
||
|
||
/* Init a node with a non-zero restored log length (initial_log_len). */
|
||
static int init_node_log_len(raft_state_t *s, raft_callbacks_t *cbs,
|
||
int n_peers, int self_idx, uint64_t initial_log_len)
|
||
{
|
||
raft_config_t cfg;
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = n_peers;
|
||
cfg.self_idx = self_idx;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_log_len = initial_log_len;
|
||
cfg.cbs = cbs;
|
||
return raft_init(s, &cfg);
|
||
}
|
||
|
||
/*
|
||
* test_leader_progress_init_single_node (raft.tla BecomeLeader, Req 3.1)
|
||
*
|
||
* Single-node immediate-win path (raft_start_election → RAFT_ELECTED_IMMEDIATELY).
|
||
* Non-zero restored log (log_len=4): on becoming leader, next_index[j] must equal
|
||
* log_len+1 (=5) and match_index[j] must equal 0 for EVERY peer j in [0, n_peers).
|
||
*/
|
||
static void test_leader_progress_init_single_node(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 1, 0, 4));
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
|
||
|
||
int rc = raft_start_election(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
|
||
for (int j = 0; j < s.n_peers; j++) {
|
||
TEST_ASSERT_EQUAL_UINT64(s.log_len + 1, s.next_index[j]);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.match_index[j]);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* test_leader_progress_init_multi_node (raft.tla BecomeLeader, Req 3.1)
|
||
*
|
||
* Multi-node become-leader path (raft_on_vote_reply → RAFT_BECAME_LEADER).
|
||
* Non-zero restored log (log_len=7): on reaching quorum, next_index[j] must equal
|
||
* log_len+1 (=8) and match_index[j] must equal 0 for EVERY peer j in [0, n_peers).
|
||
*/
|
||
static void test_leader_progress_init_multi_node(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 7));
|
||
TEST_ASSERT_EQUAL_UINT64(7, s.log_len);
|
||
|
||
/* Drive to CANDIDATE then reach quorum with one peer grant. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
|
||
int rc = raft_on_vote_reply(&s, 1, 1, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
|
||
for (int j = 0; j < s.n_peers; j++) {
|
||
TEST_ASSERT_EQUAL_UINT64(s.log_len + 1, s.next_index[j]);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.match_index[j]);
|
||
}
|
||
}
|
||
|
||
/* ---------- raft_log_append tests (task 3.1/3.2, raft.tla ClientRequest 246) ---------- */
|
||
|
||
/* Spy on_persist_entry: records every call's (index, term, bytes, len) and a
|
||
* call count. A separate fail-mode variant returns a configurable non-zero rc
|
||
* to exercise the persist-failure path (no log mutation, error propagated). */
|
||
typedef struct {
|
||
int call_count;
|
||
uint64_t last_index;
|
||
uint64_t last_term;
|
||
size_t last_len;
|
||
unsigned char last_bytes[RAFT_SIM_ENTRY_LEN];
|
||
int return_value; /* 0 = success; non-zero = persist failure */
|
||
} persist_entry_spy_t;
|
||
|
||
static persist_entry_spy_t g_pe_spy;
|
||
|
||
static void reset_pe_spy(void)
|
||
{
|
||
memset(&g_pe_spy, 0, sizeof(g_pe_spy));
|
||
g_pe_spy.return_value = 0;
|
||
}
|
||
|
||
static int spy_persist_entry(void *ctx, uint64_t index, uint64_t term,
|
||
const void *entry, size_t len)
|
||
{
|
||
(void)ctx;
|
||
g_pe_spy.call_count++;
|
||
g_pe_spy.last_index = index;
|
||
g_pe_spy.last_term = term;
|
||
g_pe_spy.last_len = len;
|
||
size_t n = len < sizeof(g_pe_spy.last_bytes) ? len : sizeof(g_pe_spy.last_bytes);
|
||
if (entry && n)
|
||
memcpy(g_pe_spy.last_bytes, entry, n);
|
||
return g_pe_spy.return_value;
|
||
}
|
||
|
||
/* Drive a node to LEADER via the multi-node quorum path (start_election →
|
||
* one peer grant), leaving log_len at the restored value. */
|
||
static void drive_to_leader(raft_state_t *s)
|
||
{
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s->role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, raft_on_vote_reply(s, 1, 1, s->term));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s->role);
|
||
}
|
||
|
||
/*
|
||
* test_log_append_leader_assigns_index_stamps_term (Req 2.1, 2.3, 2.4, 2.5)
|
||
*
|
||
* LEADER raft_log_append must:
|
||
* - assign index log_len+1 (1-based, == WAL LSN)
|
||
* - stamp the entry with s->term (currentTerm), not a zero placeholder
|
||
* - call on_persist_entry EXACTLY ONCE with (index, term, entry, len)
|
||
* - return that index via out_index
|
||
* - return RAFT_OK and advance log_len by exactly one
|
||
*/
|
||
static void test_log_append_leader_assigns_index_stamps_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_persist_entry = spy_persist_entry;
|
||
|
||
reset_mock();
|
||
reset_pe_spy();
|
||
/* restored log_len = 4 so the first append lands at index 5 */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
|
||
drive_to_leader(&s);
|
||
uint64_t term_at_append = s.term;
|
||
|
||
const unsigned char payload[] = { 0xDE, 0xAD, 0xBE, 0xEF };
|
||
uint64_t out_index = 0;
|
||
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
/* assigned index = log_len(before)+1 = 5, returned via out_index */
|
||
TEST_ASSERT_EQUAL_UINT64(5, out_index);
|
||
/* log_len advanced by exactly one */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.log_len);
|
||
|
||
/* on_persist_entry called exactly once with (index=5, term=currentTerm, bytes, len) */
|
||
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(5, g_pe_spy.last_index);
|
||
TEST_ASSERT_EQUAL_UINT64(term_at_append, g_pe_spy.last_term);
|
||
TEST_ASSERT_TRUE(term_at_append != 0); /* not a zero placeholder (Req 2.4) */
|
||
TEST_ASSERT_EQUAL_UINT64(sizeof(payload), g_pe_spy.last_len);
|
||
TEST_ASSERT_EQUAL_UINT8_ARRAY(payload, g_pe_spy.last_bytes, sizeof(payload));
|
||
}
|
||
|
||
/*
|
||
* test_log_append_follower_rejected (Req 2.2)
|
||
*
|
||
* FOLLOWER raft_log_append must return -EPERM with NO log change and NO callback.
|
||
*/
|
||
static void test_log_append_follower_rejected(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_persist_entry = spy_persist_entry;
|
||
|
||
reset_mock();
|
||
reset_pe_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
|
||
/* raft_init leaves the node RAFT_FOLLOWER */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
uint64_t out_index = 12345; /* sentinel: must remain untouched */
|
||
const unsigned char payload[] = { 0x01 };
|
||
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EPERM, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.log_len); /* no log change */
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no callback */
|
||
}
|
||
|
||
/*
|
||
* test_log_append_candidate_rejected (Req 2.2)
|
||
*
|
||
* CANDIDATE raft_log_append must also return -EPERM (only RAFT_LEADER may append),
|
||
* with NO log change and NO callback.
|
||
*/
|
||
static void test_log_append_candidate_rejected(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_persist_entry = spy_persist_entry;
|
||
|
||
reset_mock();
|
||
reset_pe_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
|
||
uint64_t out_index = 0;
|
||
const unsigned char payload[] = { 0x02 };
|
||
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EPERM, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count);
|
||
}
|
||
|
||
/*
|
||
* test_log_append_persist_failure_propagates (Req 2.3, 2.5)
|
||
*
|
||
* When on_persist_entry returns non-zero, raft_log_append must:
|
||
* - propagate that exact non-zero return value
|
||
* - NOT advance log_len (no phantom entry — persist-before-act)
|
||
*/
|
||
static void test_log_append_persist_failure_propagates(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_persist_entry = spy_persist_entry;
|
||
|
||
reset_mock();
|
||
reset_pe_spy();
|
||
g_pe_spy.return_value = -EIO; /* persist fails */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
|
||
drive_to_leader(&s);
|
||
|
||
uint64_t out_index = 0;
|
||
const unsigned char payload[] = { 0x03, 0x04 };
|
||
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
|
||
|
||
/* error propagated verbatim */
|
||
TEST_ASSERT_EQUAL_INT(-EIO, rc);
|
||
/* persist was attempted (persist-before-act) but log_len did NOT advance */
|
||
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
|
||
}
|
||
|
||
/* ---------- raft_send_append_entries tests (task 4.1/4.2, raft.tla AppendEntries 204) ---------- */
|
||
|
||
/* Spy on_send_append_entries: records the last call's arguments and a call
|
||
* count, so emission of (peer_idx, prev_log_index, prev_log_term, leader_commit,
|
||
* n_entries) can be verified exactly. */
|
||
typedef struct {
|
||
int call_count;
|
||
int last_peer_idx;
|
||
uint64_t last_prev_log_index;
|
||
uint64_t last_prev_log_term;
|
||
uint64_t last_leader_commit;
|
||
int last_n_entries;
|
||
} send_ae_spy_t;
|
||
|
||
static send_ae_spy_t g_ae_spy;
|
||
|
||
static void reset_ae_spy(void)
|
||
{
|
||
memset(&g_ae_spy, 0, sizeof(g_ae_spy));
|
||
}
|
||
|
||
static int spy_send_append_entries(void *ctx, int peer_idx,
|
||
uint64_t prev_log_index,
|
||
uint64_t prev_log_term,
|
||
uint64_t leader_commit, int n_entries)
|
||
{
|
||
(void)ctx;
|
||
g_ae_spy.call_count++;
|
||
g_ae_spy.last_peer_idx = peer_idx;
|
||
g_ae_spy.last_prev_log_index = prev_log_index;
|
||
g_ae_spy.last_prev_log_term = prev_log_term;
|
||
g_ae_spy.last_leader_commit = leader_commit;
|
||
g_ae_spy.last_n_entries = n_entries;
|
||
return 0;
|
||
}
|
||
|
||
/* Spy on_get_entry_term: returns a configurable term per 1-based index so the
|
||
* prev_log_term computation in the emit helper can be verified. An index past
|
||
* the populated range (or index 0) returns 0 = "absent". */
|
||
typedef struct {
|
||
int call_count;
|
||
uint64_t last_index;
|
||
uint64_t term_at[RAFT_SIM_MAX_LOG + 1]; /* 1-based: term_at[i] = term of entry i */
|
||
} get_term_spy_t;
|
||
|
||
static get_term_spy_t g_gt_spy;
|
||
|
||
static void reset_gt_spy(void)
|
||
{
|
||
memset(&g_gt_spy, 0, sizeof(g_gt_spy));
|
||
}
|
||
|
||
static uint64_t spy_get_entry_term(void *ctx, uint64_t index)
|
||
{
|
||
(void)ctx;
|
||
g_gt_spy.call_count++;
|
||
g_gt_spy.last_index = index;
|
||
if (index >= 1 && index <= RAFT_SIM_MAX_LOG)
|
||
return g_gt_spy.term_at[index];
|
||
return 0;
|
||
}
|
||
|
||
/*
|
||
* test_send_ae_prev_index_zero_heartbeat (Req 3.2, 3.3, 3.4)
|
||
*
|
||
* next_index[j] == 1 (so prev_log_index == 0) AND next_index[j] > log_len
|
||
* (empty log, log_len == 0) → heartbeat: prev_log_index == 0, prev_log_term == 0
|
||
* (NOT queried / treated as absent), n_entries == 0, leader_commit passed opaque.
|
||
*/
|
||
static void test_send_ae_prev_index_zero_heartbeat(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_send_append_entries = spy_send_append_entries;
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
|
||
reset_mock();
|
||
reset_ae_spy();
|
||
reset_gt_spy();
|
||
/* empty restored log → log_len = 0, next_index[j] becomes 1 on leader */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
drive_to_leader(&s);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.log_len);
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.next_index[1]); /* log_len+1 */
|
||
|
||
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/0);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.call_count);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_peer_idx);
|
||
TEST_ASSERT_EQUAL_UINT64(0, g_ae_spy.last_prev_log_index);
|
||
TEST_ASSERT_EQUAL_UINT64(0, g_ae_spy.last_prev_log_term); /* absent when prevIdx==0 */
|
||
TEST_ASSERT_EQUAL_UINT64(0, g_ae_spy.last_leader_commit);
|
||
TEST_ASSERT_EQUAL_INT(0, g_ae_spy.last_n_entries); /* heartbeat */
|
||
/* prev_log_index == 0 → on_get_entry_term must NOT be queried for it */
|
||
TEST_ASSERT_EQUAL_INT(0, g_gt_spy.call_count);
|
||
}
|
||
|
||
/*
|
||
* test_send_ae_heartbeat_when_next_past_log (Req 3.2, 3.3, 3.4)
|
||
*
|
||
* next_index[j] > log_len with prev_log_index > 0 → heartbeat (n_entries == 0).
|
||
* prev_log_index = next_index[j]-1; prev_log_term queried via on_get_entry_term.
|
||
* leader_commit passed through opaquely.
|
||
*/
|
||
static void test_send_ae_heartbeat_when_next_past_log(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_send_append_entries = spy_send_append_entries;
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
|
||
reset_mock();
|
||
reset_ae_spy();
|
||
reset_gt_spy();
|
||
/* restored log_len = 4; on becoming leader next_index[j] = 5 (= log_len+1),
|
||
* so next_index[j] > log_len → heartbeat, prev_log_index = 4 (> 0). */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
|
||
drive_to_leader(&s);
|
||
uint64_t leader_term = s.term;
|
||
g_gt_spy.term_at[4] = leader_term; /* term of entry at prev_log_index = 4 */
|
||
|
||
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/42);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.call_count);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_peer_idx);
|
||
TEST_ASSERT_EQUAL_UINT64(4, g_ae_spy.last_prev_log_index); /* next_index-1 */
|
||
TEST_ASSERT_EQUAL_UINT64(leader_term, g_ae_spy.last_prev_log_term);
|
||
TEST_ASSERT_EQUAL_UINT64(42, g_ae_spy.last_leader_commit);
|
||
TEST_ASSERT_EQUAL_INT(0, g_ae_spy.last_n_entries); /* heartbeat */
|
||
/* prev_log_index = 4 (> 0) → on_get_entry_term queried for index 4 */
|
||
TEST_ASSERT_EQUAL_UINT64(4, g_gt_spy.last_index);
|
||
TEST_ASSERT_TRUE(g_gt_spy.call_count >= 1);
|
||
}
|
||
|
||
/*
|
||
* test_send_ae_one_entry_when_next_within_log (Req 3.2, 3.3, 3.4)
|
||
*
|
||
* next_index[j] <= log_len → one entry (n_entries == 1).
|
||
* prev_log_index = next_index[j]-1; prev_log_term queried via on_get_entry_term.
|
||
*/
|
||
static void test_send_ae_one_entry_when_next_within_log(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_send_append_entries = spy_send_append_entries;
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
|
||
reset_mock();
|
||
reset_ae_spy();
|
||
reset_gt_spy();
|
||
/* restored log_len = 5; on becoming leader next_index[j] = 6. Walk one peer
|
||
* back to next_index = 3 (<= log_len) so one entry (index 3) must be sent;
|
||
* prev_log_index = 2 (> 0). */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
drive_to_leader(&s);
|
||
uint64_t leader_term = s.term;
|
||
s.next_index[1] = 3; /* construct the within-log case */
|
||
g_gt_spy.term_at[2] = leader_term; /* term of entry at prev_log_index = 2 */
|
||
|
||
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/7);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.call_count);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_peer_idx);
|
||
TEST_ASSERT_EQUAL_UINT64(2, g_ae_spy.last_prev_log_index); /* next_index-1 */
|
||
TEST_ASSERT_EQUAL_UINT64(leader_term, g_ae_spy.last_prev_log_term);
|
||
TEST_ASSERT_EQUAL_UINT64(7, g_ae_spy.last_leader_commit);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_n_entries); /* one entry */
|
||
TEST_ASSERT_EQUAL_UINT64(2, g_gt_spy.last_index);
|
||
}
|
||
|
||
/*
|
||
* test_send_ae_non_leader_emits_nothing (Req 3.5)
|
||
*
|
||
* A non-leader (FOLLOWER) raft_send_append_entries must NOT call
|
||
* on_send_append_entries at all.
|
||
*/
|
||
static void test_send_ae_non_leader_emits_nothing(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_send_append_entries = spy_send_append_entries;
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
|
||
reset_mock();
|
||
reset_ae_spy();
|
||
reset_gt_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
|
||
/* raft_init leaves the node RAFT_FOLLOWER */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/9);
|
||
|
||
/* No emission whatsoever; the helper is a no-op off the leader. */
|
||
TEST_ASSERT_EQUAL_INT(0, g_ae_spy.call_count);
|
||
/* Header contract: returns RAFT_OK on every path, including non-leader. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
}
|
||
|
||
/* ---------- raft_on_append_entries_request tests
|
||
* (tasks 5.1+5.2 UpdateTerm-first / 6.1 logOk+reject+candidate-step-down /
|
||
* 6.2+6.3 accept already-have/conflict-truncate/append;
|
||
* raft.tla HandleAppendEntriesRequest 327 + UpdateTerm 406) ----------
|
||
*
|
||
* Reuses the existing spy_persist_entry/g_pe_spy, spy_get_entry_term/g_gt_spy,
|
||
* and mock_persist/mock_become_follower (g_mock). Adds a truncate spy.
|
||
*/
|
||
|
||
/* Spy on_truncate_from: records each call's index and a call count. */
|
||
typedef struct {
|
||
int call_count;
|
||
uint64_t last_index;
|
||
} truncate_spy_t;
|
||
|
||
static truncate_spy_t g_tr_spy;
|
||
|
||
static void reset_tr_spy(void)
|
||
{
|
||
memset(&g_tr_spy, 0, sizeof(g_tr_spy));
|
||
}
|
||
|
||
static int spy_truncate_from(void *ctx, uint64_t index)
|
||
{
|
||
(void)ctx;
|
||
g_tr_spy.call_count++;
|
||
g_tr_spy.last_index = index;
|
||
return 0;
|
||
}
|
||
|
||
/* Build callbacks wired to ALL the log-replication spies + g_mock for the
|
||
* shared election-side callbacks (on_persist, on_become_follower). This lets
|
||
* the AppendEntries-request tests observe term adoption (mock_persist /
|
||
* mock_become_follower) and log mutation (pe/gt/tr spies) together. */
|
||
static raft_callbacks_t make_ae_req_cbs(void)
|
||
{
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_persist_entry = spy_persist_entry;
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_truncate_from = spy_truncate_from;
|
||
return cbs;
|
||
}
|
||
|
||
static void reset_ae_req_spies(void)
|
||
{
|
||
reset_mock();
|
||
reset_pe_spy();
|
||
reset_gt_spy();
|
||
reset_tr_spy();
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_follower_adopts_higher_term (task 5.1/5.2, Req 6.1, 6.2, 6.5 — G5)
|
||
*
|
||
* A FOLLOWER at term T receiving an AppendEntries at term T+1 must adopt T+1,
|
||
* clear voted_for (=-1), and persist (T+1, -1) via on_persist BEFORE the rest of
|
||
* the RPC — even when the local log would FAIL logOk (prev_log_index/term that
|
||
* mismatch). Term adoption happens regardless of logOk. reply_term carries the
|
||
* adopted term. (on_become_follower NOT required for a non-leader adopter.)
|
||
*/
|
||
static void test_ae_req_follower_adopts_higher_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
/* restored log_len = 2; node starts FOLLOWER at term 5, voted_for=1 */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2));
|
||
s.term = 5;
|
||
s.role = RAFT_FOLLOWER;
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 5;
|
||
/* logOk WILL fail: prev_log_index=2 expects term 9, but local entry 2 is term 5 */
|
||
g_gt_spy.term_at[2] = 5;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0xEE, sizeof(out));
|
||
const unsigned char e[] = { 0x7 };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6,
|
||
/*prev_log_index=*/2, /*prev_log_term=*/9, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/6, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
/* term adopted even though logOk fails */
|
||
TEST_ASSERT_EQUAL_UINT64(6, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
/* persisted (term=6, voted_for=-1) at least once */
|
||
TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(6, g_mock.last_persist_term);
|
||
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_persist_voted_for);
|
||
/* reply carries the adopted term; the RPC is still rejected (logOk failed) */
|
||
TEST_ASSERT_EQUAL_UINT64(6, out.reply_term);
|
||
TEST_ASSERT_EQUAL_INT(0, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_leader_adopts_higher_term_steps_down (task 5.1/5.2, Req 6.1, 6.3)
|
||
*
|
||
* A LEADER at term T receiving an AppendEntries at term T+1 must adopt T+1,
|
||
* clear vote, step down to FOLLOWER, persist (T+1,-1), AND fire on_become_follower
|
||
* with (T+1, leader_idx). Applies before logOk.
|
||
*/
|
||
static void test_ae_req_leader_adopts_higher_term_steps_down(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
/* drive to LEADER at term 1, then a higher-term AE arrives */
|
||
drive_to_leader(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
int bf_before = g_mock.become_follower_count;
|
||
uint64_t higher = s.term + 1;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
/* heartbeat (entries_len=0, prev_log_index=0 → logOk trivially true) */
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/2, higher,
|
||
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
|
||
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(higher, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
/* on_become_follower fired once for the step-down with (higher, leader_idx=2) */
|
||
TEST_ASSERT_EQUAL_INT(bf_before + 1, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_UINT64(higher, g_mock.last_become_follower_term);
|
||
TEST_ASSERT_EQUAL_INT(2, g_mock.last_become_follower_leader_idx);
|
||
/* persisted the adopted term */
|
||
TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(higher, g_mock.last_persist_term);
|
||
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_persist_voted_for);
|
||
/* the higher-term heartbeat is accepted (logOk trivially true) */
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(higher, out.reply_term);
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_heartbeat_adopts_higher_term (task 5.1/5.2, Req 6.1, 6.4 — G5)
|
||
*
|
||
* A 0-entry heartbeat at a strictly-higher term still triggers UpdateTerm
|
||
* (adoption applies on EVERY RPC incl. heartbeat), even when the heartbeat's
|
||
* prev_log_index would fail logOk on the local log.
|
||
*/
|
||
static void test_ae_req_heartbeat_adopts_higher_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 1));
|
||
s.term = 3;
|
||
s.role = RAFT_FOLLOWER;
|
||
/* logOk fails: prev_log_index=1 expects term 8 but local entry 1 is term 3 */
|
||
g_gt_spy.term_at[1] = 3;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/7,
|
||
/*prev_log_index=*/1, /*prev_log_term=*/8, /*leader_commit=*/0,
|
||
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
/* adopted despite logOk failure */
|
||
TEST_ASSERT_EQUAL_UINT64(7, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(7, g_mock.last_persist_term);
|
||
/* heartbeat with a failing logOk is rejected, but at the adopted term */
|
||
TEST_ASSERT_EQUAL_INT(0, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(7, out.reply_term);
|
||
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_equal_term_no_term_change (Req 6.5)
|
||
*
|
||
* On an EQUAL-term AppendEntries, the handler must NOT change term or voted_for,
|
||
* and must NOT persist a term adoption.
|
||
*/
|
||
static void test_ae_req_equal_term_no_term_change(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
s.term = 4;
|
||
s.role = RAFT_FOLLOWER;
|
||
s.voted_for = 2;
|
||
s.voted_for_term = 4;
|
||
int persist_before = g_mock.persist_count;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
/* empty log, prev_log_index=0 → logOk true → append branch (heartbeat) */
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4,
|
||
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
|
||
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.term);
|
||
TEST_ASSERT_EQUAL_INT(2, s.voted_for); /* untouched */
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.voted_for_term);
|
||
/* no on_persist for term adoption on equal term */
|
||
TEST_ASSERT_EQUAL_INT(persist_before, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success); /* equal-term heartbeat accepted */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_stale_term_rejected (task 6.1, Req 4.2)
|
||
*
|
||
* term < currentTerm → reject (success=0, match_index=0, reply_term=currentTerm),
|
||
* no term change, no log mutation.
|
||
*/
|
||
static void test_ae_req_stale_term_rejected(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2));
|
||
s.term = 9;
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0x1 };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/3,
|
||
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/3, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
|
||
TEST_ASSERT_EQUAL_UINT64(9, out.reply_term); /* current term */
|
||
TEST_ASSERT_EQUAL_UINT64(9, s.term); /* unchanged */
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no log write */
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_logok_prev_zero_accepts_append (task 6.1/6.3, Req 4.1, 4.6)
|
||
*
|
||
* prev_log_index == 0 → logOk true unconditionally; the local log ends at 0
|
||
* (empty) so the one incoming entry is appended via on_persist_entry at index 1;
|
||
* success, match_index = prev_log_index + entries_len = 1; log_len grows to 1.
|
||
*/
|
||
static void test_ae_req_logok_prev_zero_accepts_append(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
s.term = 2;
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0xAA, 0xBB };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/2,
|
||
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/2, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(1, out.match_index); /* prev(0) + entries_len(1) */
|
||
/* appended at index 1 with the incoming entry term */
|
||
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_pe_spy.last_index);
|
||
TEST_ASSERT_EQUAL_UINT64(2, g_pe_spy.last_term);
|
||
TEST_ASSERT_EQUAL_UINT8_ARRAY(e, g_pe_spy.last_bytes, sizeof(e));
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* append, no truncate */
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.log_len); /* log extended */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_logok_match_accepts (task 6.1/6.3, Req 4.1)
|
||
*
|
||
* prev_log_index > 0 and on_get_entry_term(prev_log_index) == prev_log_term →
|
||
* logOk true. Append at index prev+1 (local log ends at prev) → success,
|
||
* match_index = prev + entries_len.
|
||
*/
|
||
static void test_ae_req_logok_match_accepts(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3));
|
||
s.term = 4;
|
||
s.role = RAFT_FOLLOWER;
|
||
/* local entry 3 has term 4 == prev_log_term ⇒ logOk true; index 4 absent */
|
||
g_gt_spy.term_at[3] = 4;
|
||
/* g_gt_spy.term_at[4] == 0 → absent → append branch */
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0xC0 };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4,
|
||
/*prev_log_index=*/3, /*prev_log_term=*/4, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/4, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* prev(3) + 1 */
|
||
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(4, g_pe_spy.last_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_logok_mismatch_rejected (task 6.1, Req 4.1, 4.2 — G2)
|
||
*
|
||
* prev_log_index > 0 and on_get_entry_term(prev_log_index) != prev_log_term →
|
||
* logOk FALSE → follower at equal term rejects (success=0, match_index=0),
|
||
* with no log mutation.
|
||
*/
|
||
static void test_ae_req_logok_mismatch_rejected(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3));
|
||
s.term = 4;
|
||
s.role = RAFT_FOLLOWER;
|
||
/* local entry 3 has term 2, but prev_log_term says 4 → logOk false */
|
||
g_gt_spy.term_at[3] = 2;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0xD0 };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4,
|
||
/*prev_log_index=*/3, /*prev_log_term=*/4, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/4, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
|
||
TEST_ASSERT_EQUAL_UINT64(4, out.reply_term);
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.log_len); /* unchanged */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_candidate_equal_term_steps_down (task 6.1, Req 4.3)
|
||
*
|
||
* A CANDIDATE at EQUAL term receiving a logOk AppendEntries transitions to
|
||
* FOLLOWER (return-to-follower branch) and processes the accept.
|
||
*/
|
||
static void test_ae_req_candidate_equal_term_steps_down(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
/* become CANDIDATE at term 1 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
uint64_t cand_term = s.term;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
/* equal-term heartbeat, prev_log_index=0 → logOk true */
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, cand_term,
|
||
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
|
||
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stepped down */
|
||
TEST_ASSERT_EQUAL_UINT64(cand_term, s.term); /* no term change (equal) */
|
||
TEST_ASSERT_EQUAL_INT(1, out.success); /* heartbeat accepted */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_candidate_equal_term_steps_down_then_rejects (task 6.1, Req 4.2/4.3)
|
||
*
|
||
* A CANDIDATE at EQUAL term receiving an AppendEntries whose prev entry does
|
||
* NOT match (logOk false) must still step down to FOLLOWER (step 3, before
|
||
* logOk) AND then reject (step 5). This pins the step ordering: a mutation that
|
||
* moved candidate step-down after the logOk reject would leave the node a
|
||
* CANDIDATE here and be caught.
|
||
*/
|
||
static void test_ae_req_candidate_equal_term_steps_down_then_rejects(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
uint64_t cand_term = s.term;
|
||
|
||
/* prev_log_index=3 within log, but local term there (4) != prev_log_term (9)
|
||
* → logOk false → reject, yet the candidate must have stepped down first. */
|
||
g_gt_spy.term_at[3] = 4;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, cand_term,
|
||
/*prev_log_index=*/3, /*prev_log_term=*/9, /*leader_commit=*/0,
|
||
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stepped down BEFORE logOk */
|
||
TEST_ASSERT_EQUAL_UINT64(cand_term, s.term); /* equal term, no change */
|
||
TEST_ASSERT_EQUAL_INT(0, out.success); /* logOk failed → reject */
|
||
TEST_ASSERT_EQUAL_UINT64(0, out.match_index); /* reject → match_index 0 */
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no persist on reject */
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate on reject */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_already_have_heartbeat (task 6.2, Req 4.4)
|
||
*
|
||
* entries_len == 0 (heartbeat) with logOk → already-satisfied: success,
|
||
* match_index = prev + 0 (= prev), NO log rewrite, NO truncate.
|
||
*/
|
||
static void test_ae_req_already_have_heartbeat(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
s.term = 6;
|
||
s.role = RAFT_FOLLOWER;
|
||
g_gt_spy.term_at[5] = 6; /* prev_log_index=5 matches prev_log_term=6 */
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6,
|
||
/*prev_log_index=*/5, /*prev_log_term=*/6, /*leader_commit=*/0,
|
||
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(5, out.match_index); /* prev(5) + entries_len(0) */
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no rewrite */
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.log_len); /* heartbeat must NOT shrink log */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_already_have_matching_term (task 6.2, Req 4.4)
|
||
*
|
||
* entries_len == 1 but the local entry at prev+1 already has the SAME term as the
|
||
* incoming entry → already-satisfied: success, match_index = prev + entries_len,
|
||
* NO log rewrite, NO truncate.
|
||
*/
|
||
static void test_ae_req_already_have_matching_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
s.term = 6;
|
||
s.role = RAFT_FOLLOWER;
|
||
g_gt_spy.term_at[3] = 6; /* prev_log_index=3 matches prev_log_term=6 (logOk) */
|
||
g_gt_spy.term_at[4] = 6; /* local entry at index 4 already term 6 == entry_term */
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0x55 };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6,
|
||
/*prev_log_index=*/3, /*prev_log_term=*/6, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/6, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* prev(3) + 1 */
|
||
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* already have it — no rewrite */
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.log_len); /* must NOT shrink */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_conflict_truncate_then_persist (task 6.2/6.3, Req 4.5 — G3)
|
||
*
|
||
* logOk holds, but the local entry at index = prev+1 exists with a DIFFERENT term
|
||
* than the incoming entry → on_truncate_from(index) THEN on_persist_entry(index,
|
||
* entry_term, entry); success, match_index = index (= prev + entries_len).
|
||
* The truncate MUST be immediately followed by the re-persist of the leader entry.
|
||
*/
|
||
static void test_ae_req_conflict_truncate_then_persist(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
s.term = 7;
|
||
s.role = RAFT_FOLLOWER;
|
||
g_gt_spy.term_at[3] = 7; /* prev_log_index=3, prev_log_term=7 → logOk */
|
||
g_gt_spy.term_at[4] = 4; /* local entry 4 has term 4 (divergent) */
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0x99, 0xAA };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/7,
|
||
/*prev_log_index=*/3, /*prev_log_term=*/7, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/7, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* index = prev+1 */
|
||
/* truncate at the conflicting index, then re-persist the leader's entry there */
|
||
TEST_ASSERT_EQUAL_INT(1, g_tr_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(4, g_tr_spy.last_index);
|
||
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(4, g_pe_spy.last_index);
|
||
TEST_ASSERT_EQUAL_UINT64(7, g_pe_spy.last_term); /* the leader's entry_term */
|
||
TEST_ASSERT_EQUAL_UINT8_ARRAY(e, g_pe_spy.last_bytes, sizeof(e));
|
||
/* log_len is at least prev + entries_len after a conflict-and-append */
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_append_extends_log (task 6.3, Req 4.6)
|
||
*
|
||
* logOk holds and the local log ends exactly at prev_log_index (no entry at
|
||
* prev+1) → append via on_persist_entry; success; match_index = prev + entries_len;
|
||
* log_len extended to prev + entries_len.
|
||
*/
|
||
static void test_ae_req_append_extends_log(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2));
|
||
s.term = 5;
|
||
s.role = RAFT_FOLLOWER;
|
||
g_gt_spy.term_at[2] = 5; /* prev_log_index=2, prev_log_term=5 → logOk */
|
||
/* index 3 absent (term_at[3]==0), log_len==2 == prev_log_index → append */
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0x11, 0x22, 0x33 };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/5,
|
||
/*prev_log_index=*/2, /*prev_log_term=*/5, /*leader_commit=*/0,
|
||
e, sizeof(e), /*entry_term=*/5, /*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(3, out.match_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate on plain append */
|
||
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(3, g_pe_spy.last_index);
|
||
TEST_ASSERT_EQUAL_UINT64(5, g_pe_spy.last_term);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.log_len); /* extended */
|
||
}
|
||
|
||
/*
|
||
* test_ae_req_leader_commit_not_acted (Req 4.x / R3.4 — commit opaque)
|
||
*
|
||
* A large leader_commit must NOT advance any commit index or otherwise leak into
|
||
* library state — the handler only fills *out and mutates the log per the accept
|
||
* branch. (No commit field exists in raft_state_t; this asserts the accept path
|
||
* is unaffected by leader_commit value.)
|
||
*/
|
||
static void test_ae_req_leader_commit_not_acted(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_ae_req_cbs();
|
||
|
||
reset_ae_req_spies();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
s.term = 1;
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
raft_append_result_t out;
|
||
memset(&out, 0, sizeof(out));
|
||
const unsigned char e[] = { 0x7E };
|
||
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/1,
|
||
/*prev_log_index=*/0, /*prev_log_term=*/0,
|
||
/*leader_commit=*/999999, e, sizeof(e), /*entry_term=*/1,
|
||
/*entries_len=*/1, &out);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, out.success);
|
||
TEST_ASSERT_EQUAL_UINT64(1, out.match_index); /* unaffected by leader_commit */
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.log_len);
|
||
}
|
||
|
||
/* ---------- raft_on_append_entries_reply tests (task 7.1/7.2,
|
||
* raft.tla HandleAppendEntriesResponse 393) ---------- */
|
||
|
||
/*
|
||
* test_ae_reply_success_advances_indices (Req 5.1, 5.2)
|
||
*
|
||
* A success reply at the leader's current term must:
|
||
* - set next_index[peer] = match_index + 1
|
||
* - set match_index[peer] = match_index
|
||
* - return RAFT_OK
|
||
* It must NOT advance any commit index (no commit field exists; not asserted here).
|
||
*/
|
||
static void test_ae_reply_success_advances_indices(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
/* restored log_len = 5; after drive_to_leader next_index[peer]=6, match_index[peer]=0 */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
drive_to_leader(&s);
|
||
|
||
/* peer 1 acknowledged up through index 4 */
|
||
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/1,
|
||
/*match_index=*/4, /*reply_term=*/s.term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.next_index[1]); /* match_index + 1 */
|
||
TEST_ASSERT_EQUAL_UINT64(4, s.match_index[1]); /* == match_index */
|
||
}
|
||
|
||
/*
|
||
* test_ae_reply_failure_decrements_next_index_floor (Req 5.1, 5.3)
|
||
*
|
||
* A failure reply at the leader's current term must:
|
||
* - set next_index[peer] = Max(next_index[peer] - 1, 1) (decrement, floored at 1)
|
||
* - leave match_index[peer] unchanged
|
||
* - stay at 1 when next_index[peer] is already 1 (verify the floor)
|
||
* - return RAFT_OK
|
||
*/
|
||
static void test_ae_reply_failure_decrements_next_index_floor(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
/* restored log_len = 5 → next_index[peer] = 6 after drive_to_leader */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
drive_to_leader(&s);
|
||
|
||
/* seed a non-zero match_index to prove failure leaves it untouched */
|
||
s.match_index[1] = 3;
|
||
|
||
/* first failure: 6 → 5 */
|
||
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/0,
|
||
/*match_index=*/0, /*reply_term=*/s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.next_index[1]);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.match_index[1]); /* unchanged */
|
||
|
||
/* drive next_index down to the floor */
|
||
s.next_index[1] = 1;
|
||
rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/0,
|
||
/*match_index=*/0, /*reply_term=*/s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.next_index[1]); /* floored at 1, not 0 */
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.match_index[1]); /* still unchanged */
|
||
}
|
||
|
||
/*
|
||
* test_ae_reply_stale_term_ignored (Req 5.4, raft.tla 415)
|
||
*
|
||
* A reply whose reply_term != s->term is a stale-term drop: neither next_index
|
||
* nor match_index for the peer may change. Returns RAFT_OK.
|
||
*/
|
||
static void test_ae_reply_stale_term_ignored(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
|
||
drive_to_leader(&s);
|
||
|
||
/* snapshot current per-peer indices (next_index[1]=6, match_index[1]=0) */
|
||
uint64_t next_before = s.next_index[1];
|
||
uint64_t match_before = s.match_index[1];
|
||
|
||
/* stale success reply (older term) must be ignored */
|
||
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/1,
|
||
/*match_index=*/9,
|
||
/*reply_term=*/s.term - 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(next_before, s.next_index[1]);
|
||
TEST_ASSERT_EQUAL_UINT64(match_before, s.match_index[1]);
|
||
|
||
/* stale failure reply (newer term, e.g. from a future leader) must also be ignored */
|
||
rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/0,
|
||
/*match_index=*/0,
|
||
/*reply_term=*/s.term + 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(next_before, s.next_index[1]);
|
||
TEST_ASSERT_EQUAL_UINT64(match_before, s.match_index[1]);
|
||
}
|
||
|
||
/* ---------- raft_advance_commit tests (task 3.1/3.2/3.3, raft.tla
|
||
* AdvanceCommitIndex 259/270 — current-term Figure-8 rule) ---------- */
|
||
|
||
/* Spy on_commit_advanced: records the last reported commit_index and a fire
|
||
* counter so tests can assert the strict-advance / fire-once semantics (the
|
||
* library fires this ONLY on a strict commit_index advance). */
|
||
typedef struct {
|
||
int call_count;
|
||
uint64_t last_commit_index;
|
||
} commit_advanced_spy_t;
|
||
|
||
static commit_advanced_spy_t g_ca_spy;
|
||
|
||
static void reset_ca_spy(void)
|
||
{
|
||
memset(&g_ca_spy, 0, sizeof(g_ca_spy));
|
||
}
|
||
|
||
static void spy_commit_advanced(void *ctx, uint64_t commit_index)
|
||
{
|
||
(void)ctx;
|
||
g_ca_spy.call_count++;
|
||
g_ca_spy.last_commit_index = commit_index;
|
||
}
|
||
|
||
/*
|
||
* test_advance_commit_advances_current_term (Req 1.2-1.7, 2.2, 2.3)
|
||
*
|
||
* Leader, term=2, log_len=3. A quorum (self + peer 1 of a 3-node cluster) agrees
|
||
* through index 3, and the entry at index 3 is from the CURRENT term (2). The
|
||
* commit index must advance to 3 and on_commit_advanced(3) must fire exactly once.
|
||
*/
|
||
static void test_advance_commit_advances_current_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_commit_advanced = spy_commit_advanced;
|
||
|
||
reset_mock();
|
||
reset_gt_spy();
|
||
reset_ca_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
|
||
/* Hand-set leader state directly (do not exercise the election path). */
|
||
s.role = RAFT_LEADER;
|
||
s.term = 2;
|
||
s.log_len = 3;
|
||
/* every entry 1..3 is from the current term 2 */
|
||
g_gt_spy.term_at[1] = 2;
|
||
g_gt_spy.term_at[2] = 2;
|
||
g_gt_spy.term_at[3] = 2;
|
||
/* peer 1 has replicated through index 3 (self counts as log_len=3) → quorum (2 of 3) */
|
||
s.match_index[1] = 3;
|
||
s.match_index[2] = 0;
|
||
|
||
int rc = raft_advance_commit(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count);
|
||
TEST_ASSERT_EQUAL_UINT64(3, g_ca_spy.last_commit_index);
|
||
}
|
||
|
||
/*
|
||
* test_advance_commit_figure8_hold_previous_term (Req 1.2-1.7, 2.2, 2.3)
|
||
*
|
||
* Figure-8 safety: a quorum agrees through index 2, but the entry at index 2 is
|
||
* from a PREVIOUS term (1) while the leader's current term is 3, and no
|
||
* current-term entry sits above it that also has quorum. The commit index must
|
||
* NOT advance and on_commit_advanced must NOT fire.
|
||
*/
|
||
static void test_advance_commit_figure8_hold_previous_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_commit_advanced = spy_commit_advanced;
|
||
|
||
reset_mock();
|
||
reset_gt_spy();
|
||
reset_ca_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
|
||
s.role = RAFT_LEADER;
|
||
s.term = 3;
|
||
s.log_len = 3;
|
||
/* entries 1,2 are from a previous term (1); entry 3 (current term) is NOT
|
||
* yet replicated to a quorum. */
|
||
g_gt_spy.term_at[1] = 1;
|
||
g_gt_spy.term_at[2] = 1;
|
||
g_gt_spy.term_at[3] = 3;
|
||
/* peer 1 agrees only through index 2 (a previous-term entry); self has 3. */
|
||
s.match_index[1] = 2;
|
||
s.match_index[2] = 0;
|
||
|
||
int rc = raft_advance_commit(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index); /* held — not advanced over prev-term */
|
||
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
|
||
}
|
||
|
||
/*
|
||
* test_advance_commit_no_quorum (Req 1.2-1.7, 2.2, 2.3)
|
||
*
|
||
* No index reaches quorum (a 3-node cluster where no peer has replicated any
|
||
* entry; only self agrees). agreeIndexes is empty → commit_index unchanged, no
|
||
* callback.
|
||
*/
|
||
static void test_advance_commit_no_quorum(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_commit_advanced = spy_commit_advanced;
|
||
|
||
reset_mock();
|
||
reset_gt_spy();
|
||
reset_ca_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
|
||
s.role = RAFT_LEADER;
|
||
s.term = 2;
|
||
s.log_len = 3;
|
||
g_gt_spy.term_at[1] = 2;
|
||
g_gt_spy.term_at[2] = 2;
|
||
g_gt_spy.term_at[3] = 2;
|
||
/* no peer has replicated anything → only self agrees (1 of 3 — not quorum) */
|
||
s.match_index[1] = 0;
|
||
s.match_index[2] = 0;
|
||
|
||
int rc = raft_advance_commit(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
|
||
}
|
||
|
||
/*
|
||
* test_advance_commit_monotonic (Req 1.2-1.7, 2.2, 2.3)
|
||
*
|
||
* A later call that computes a SMALLER Max(agreeIndexes) must never lower
|
||
* commit_index. First advance to 3, then drop a peer's match_index so the newly
|
||
* computed Max is 1 (< 3): commit_index stays 3 and no second callback fires.
|
||
*/
|
||
static void test_advance_commit_monotonic(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_commit_advanced = spy_commit_advanced;
|
||
|
||
reset_mock();
|
||
reset_gt_spy();
|
||
reset_ca_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
|
||
s.role = RAFT_LEADER;
|
||
s.term = 2;
|
||
s.log_len = 3;
|
||
g_gt_spy.term_at[1] = 2;
|
||
g_gt_spy.term_at[2] = 2;
|
||
g_gt_spy.term_at[3] = 2;
|
||
s.match_index[1] = 3;
|
||
s.match_index[2] = 0;
|
||
|
||
/* first advance → commit_index = 3 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_advance_commit(&s));
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count);
|
||
|
||
/* now the peer regresses to index 1; new Max(agreeIndexes) = 1 < 3 */
|
||
s.match_index[1] = 1;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_advance_commit(&s));
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index); /* never lowered */
|
||
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count); /* no second fire */
|
||
}
|
||
|
||
/*
|
||
* test_advance_commit_non_leader_eperm (Req 1.2-1.7, 2.2, 2.3)
|
||
*
|
||
* Off the leader, raft_advance_commit returns -EPERM, makes no change to
|
||
* commit_index, and fires no callback.
|
||
*/
|
||
static void test_advance_commit_non_leader_eperm(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_commit_advanced = spy_commit_advanced;
|
||
|
||
reset_mock();
|
||
reset_gt_spy();
|
||
reset_ca_spy();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
|
||
|
||
s.role = RAFT_FOLLOWER;
|
||
s.term = 2;
|
||
s.log_len = 3;
|
||
g_gt_spy.term_at[1] = 2;
|
||
g_gt_spy.term_at[2] = 2;
|
||
g_gt_spy.term_at[3] = 2;
|
||
s.match_index[1] = 3;
|
||
s.match_index[2] = 3;
|
||
|
||
int rc = raft_advance_commit(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EPERM, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
|
||
}
|
||
|
||
/*
|
||
* test_ae_reply_does_not_advance_commit (task 3.3, Req 2.6)
|
||
*
|
||
* Commit-advance is SEPARATE from the AppendEntries-reply handler. A
|
||
* raft_on_append_entries_reply that updates match_index[] to a committable quorum
|
||
* must NOT, by itself, change commit_index or fire on_commit_advanced. Only a
|
||
* subsequent raft_advance_commit advances the commit index.
|
||
*
|
||
* NOTE: raft_on_append_entries_reply is owned upstream (raft-log-replication) and
|
||
* is NOT modified by this spec; this test only asserts that contract holds.
|
||
*/
|
||
static void test_ae_reply_does_not_advance_commit(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
cbs.on_get_entry_term = spy_get_entry_term;
|
||
cbs.on_commit_advanced = spy_commit_advanced;
|
||
|
||
reset_mock();
|
||
reset_gt_spy();
|
||
reset_ca_spy();
|
||
/* restored log_len = 3 → after drive_to_leader next_index[peer]=4 */
|
||
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3));
|
||
drive_to_leader(&s);
|
||
|
||
/* all 3 entries are from the leader's current term → would be committable */
|
||
g_gt_spy.term_at[1] = s.term;
|
||
g_gt_spy.term_at[2] = s.term;
|
||
g_gt_spy.term_at[3] = s.term;
|
||
|
||
uint64_t commit_before = s.commit_index;
|
||
|
||
/* a success reply moving peer 1 to a committable quorum index */
|
||
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/1,
|
||
/*match_index=*/3, /*reply_term=*/s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.match_index[1]); /* match recorded */
|
||
|
||
/* the reply ALONE must not move commit or fire the callback */
|
||
TEST_ASSERT_EQUAL_UINT64(commit_before, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
|
||
|
||
/* only the explicit commit-advance step advances commit_index */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_advance_commit(&s));
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count);
|
||
}
|
||
|
||
/* ---------- raft_on_election_timeout tests (task 3.5) ---------- */
|
||
|
||
/*
|
||
* test_election_timeout_undecided
|
||
*
|
||
* Node is CANDIDATE, election_decided=0.
|
||
* raft_on_election_timeout must:
|
||
* - set role=FOLLOWER
|
||
* - set election_decided=1
|
||
* - fire on_become_follower once
|
||
* - fire on_cancel_timeout once
|
||
* - trace "election_timeout"
|
||
*/
|
||
static void test_election_timeout_undecided(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, 3, 0));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 2;
|
||
s.election_decided = 0;
|
||
|
||
int become_follower_before = g_mock.become_follower_count;
|
||
|
||
raft_on_election_timeout(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_follower fired once (with current term, leader_idx=-1) */
|
||
TEST_ASSERT_EQUAL_INT(become_follower_before + 1, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_UINT64(2, g_mock.last_become_follower_term);
|
||
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
|
||
|
||
/* on_cancel_timeout fired once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
|
||
|
||
/* trace fired with "election_timeout" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("election_timeout", g_mock.last_trace_event);
|
||
}
|
||
|
||
/*
|
||
* test_election_timeout_already_decided
|
||
*
|
||
* election_decided=1 already set (election outcome already fired).
|
||
* raft_on_election_timeout must:
|
||
* - fire on_cancel_timeout once (stale timer cleanup, Req 6.3)
|
||
* - NOT fire an additional on_become_follower
|
||
* - trace "election_timeout_noop"
|
||
*/
|
||
static void test_election_timeout_already_decided(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, 3, 0));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 2;
|
||
s.election_decided = 1; /* already decided */
|
||
|
||
/* Snapshot become_follower_count before the call */
|
||
int become_follower_before = g_mock.become_follower_count;
|
||
|
||
raft_on_election_timeout(&s);
|
||
|
||
/* on_cancel_timeout must fire once (stale timer cleanup, Req 6.3) */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
|
||
|
||
/* on_become_follower must NOT have fired an additional time */
|
||
TEST_ASSERT_EQUAL_INT(become_follower_before, g_mock.become_follower_count);
|
||
|
||
/* trace fired with "election_timeout_noop" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("election_timeout_noop", g_mock.last_trace_event);
|
||
}
|
||
|
||
/* ---------- raft_on_leader_lost tests (task 3.5) ---------- */
|
||
|
||
/*
|
||
* test_leader_lost_clears_voted_for
|
||
*
|
||
* Node had voted_for=1. raft_on_leader_lost must:
|
||
* - set voted_for=-1
|
||
* - set role=FOLLOWER
|
||
* - fire on_start_election_after_delay with delay = my_priority * election_timeout_ms
|
||
* - trace "leader_lost"
|
||
*/
|
||
static void test_leader_lost_clears_voted_for(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
|
||
raft_config_t cfg;
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.election_timeout_ms = 500;
|
||
cfg.my_priority = 2;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
s.voted_for = 1;
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
raft_on_leader_lost(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
/* on_start_election_after_delay fired once with delay = priority * timeout */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
|
||
TEST_ASSERT_EQUAL_UINT32(2 * 500, g_mock.last_start_election_delay);
|
||
|
||
/* trace fired with "leader_lost" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("leader_lost", g_mock.last_trace_event);
|
||
}
|
||
|
||
/* ---------- raft_check_and_start_election tests (task 3.5) ---------- */
|
||
|
||
/*
|
||
* test_check_and_start_voted_for_other
|
||
*
|
||
* voted_for=1, self_idx=0 (voted for a different candidate).
|
||
* Must return RAFT_SKIP_ELECTION, role stays FOLLOWER, no election started.
|
||
* Trace "jitter_skip" fires.
|
||
*/
|
||
static void test_check_and_start_voted_for_other(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, 3, 0));
|
||
s.voted_for = 1; /* voted for peer 1, not self (0) */
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
int rc = raft_check_and_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
/* No election RPCs must have fired */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
|
||
|
||
/* trace fired with "jitter_skip" */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
|
||
TEST_ASSERT_EQUAL_STRING("jitter_skip", g_mock.last_trace_event);
|
||
}
|
||
|
||
/*
|
||
* test_check_and_start_not_voted
|
||
*
|
||
* voted_for==-1 (no vote cast yet).
|
||
* Must call raft_start_election internally and return RAFT_ELECTION_STARTED.
|
||
* role becomes CANDIDATE (3-node cluster).
|
||
*/
|
||
static void test_check_and_start_not_voted(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.voted_for = -1;
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
int rc = raft_check_and_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
|
||
/* raft_start_election must have fired vote requests */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.send_vote_request_count);
|
||
}
|
||
|
||
/*
|
||
* test_check_and_start_voted_for_self
|
||
*
|
||
* voted_for==self_idx (we previously voted for ourselves — this means
|
||
* we already started an election, so start again to re-enter candidacy).
|
||
* Must return RAFT_ELECTION_STARTED, not RAFT_SKIP_ELECTION.
|
||
*/
|
||
static void test_check_and_start_voted_for_self(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.voted_for = 0; /* voted for self (self_idx=0) */
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
int rc = raft_check_and_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
|
||
}
|
||
|
||
/* ---------- raft_snapshot tests ---------- */
|
||
|
||
static void test_raft_snapshot_returns_current_state(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_snapshot_t snap;
|
||
raft_callbacks_t cbs = make_full_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.initial_term = 5;
|
||
cfg.initial_voted_for = 1;
|
||
cfg.cbs = &cbs;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
/* manually set a few fields to distinguish from zero defaults */
|
||
s.votes_granted = 2;
|
||
s.election_decided = 1;
|
||
s.voted_for_term = 5;
|
||
|
||
memset(&snap, 0xFF, sizeof(snap)); /* canary */
|
||
raft_snapshot(&s, &snap);
|
||
|
||
TEST_ASSERT_EQUAL_UINT64(5, snap.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, snap.role);
|
||
TEST_ASSERT_EQUAL_INT(1, snap.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, snap.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(2, snap.votes_granted);
|
||
TEST_ASSERT_EQUAL_INT(1, snap.election_decided);
|
||
}
|
||
|
||
/* ---------- simulator tests (task 4.1) ---------- */
|
||
|
||
#ifdef XCE_TESTING
|
||
|
||
/*
|
||
* test_sim_conformance_types_compile (raft-sim-conformance task 1.1)
|
||
*
|
||
* Structural / compile-reference test for the conformance harness data types
|
||
* added to the XCE_TESTING section of raft.h. This task is non-behavioral: it
|
||
* only declares new types and struct fields. The test proves the new types are
|
||
* usable (construct a raft_sim_msg_t / raft_sim_log_entry_t / raft_sim_action_t
|
||
* and read fields back) and that raft_sim_cluster_t / raft_sim_node_t carry the
|
||
* new fixed arrays (sizeof grows to include bag[]/trace[]/applied[]).
|
||
*/
|
||
static void test_sim_conformance_types_compile(void)
|
||
{
|
||
/* Bag message round-trips its fields. */
|
||
raft_sim_msg_t m;
|
||
memset(&m, 0, sizeof(m));
|
||
m.kind = RAFT_SIM_MSG_AE_REQ;
|
||
m.src = 1;
|
||
m.dst = 2;
|
||
m.term = 7;
|
||
m.entry_term = 7;
|
||
m.entry_value = 42;
|
||
m.entries_len = 1;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_REQ, m.kind);
|
||
TEST_ASSERT_EQUAL_UINT64(42, m.entry_value);
|
||
TEST_ASSERT_EQUAL_INT(1, m.entries_len);
|
||
|
||
/* Lightweight (term, value) log entry. */
|
||
raft_sim_log_entry_t e = { .term = 3, .value = 99 };
|
||
TEST_ASSERT_EQUAL_UINT64(3, e.term);
|
||
TEST_ASSERT_EQUAL_UINT64(99, e.value);
|
||
|
||
/* Replayable action record over the Next-action enum. */
|
||
raft_sim_action_t a;
|
||
memset(&a, 0, sizeof(a));
|
||
a.kind = RAFT_SIM_ACT_APPEND_ENTRIES;
|
||
a.actor = 0;
|
||
a.msg_index = -1;
|
||
a.term = 7;
|
||
a.index = 5;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_ACT_APPEND_ENTRIES, a.kind);
|
||
TEST_ASSERT_EQUAL_UINT64(5, a.index);
|
||
|
||
/* New node + cluster fields exist and are addressable. */
|
||
raft_sim_node_t n;
|
||
memset(&n, 0, sizeof(n));
|
||
n.applied[0] = 11;
|
||
n.persisted_term = 4;
|
||
n.persisted_voted_for = 2;
|
||
TEST_ASSERT_EQUAL_UINT64(11, n.applied[0]);
|
||
TEST_ASSERT_EQUAL_INT(2, n.persisted_voted_for);
|
||
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
c.seed = 0xDEADBEEFULL;
|
||
c.prng = c.seed;
|
||
c.bag_count = 0;
|
||
c.trace_len = 0;
|
||
c.bag[0] = m;
|
||
c.trace[0] = a;
|
||
TEST_ASSERT_EQUAL_UINT64(0xDEADBEEFULL, c.seed);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_REQ, c.bag[0].kind);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_ACT_APPEND_ENTRIES, c.trace[0].kind);
|
||
|
||
/* sizeof grows to include the new fixed arrays (bounds are positive). */
|
||
TEST_ASSERT_TRUE(RAFT_SIM_MAX_MSGS > 0);
|
||
TEST_ASSERT_TRUE(RAFT_SIM_MAX_STEPS > 0);
|
||
TEST_ASSERT_TRUE(sizeof(c.bag) == sizeof(raft_sim_msg_t) * RAFT_SIM_MAX_MSGS);
|
||
TEST_ASSERT_TRUE(sizeof(c.trace) == sizeof(raft_sim_action_t) * RAFT_SIM_MAX_STEPS);
|
||
}
|
||
|
||
/*
|
||
* test_sim_init_3node
|
||
*
|
||
* raft_sim_init(c, 3, 2000) returns 0.
|
||
* All 3 nodes have: role=FOLLOWER, term=0, voted_for=-1.
|
||
* leader_count=0, pending_count=0.
|
||
*/
|
||
static void test_sim_init_3node(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0xFF, sizeof(c)); /* canary — force all fields to non-zero */
|
||
|
||
int rc = raft_sim_init(&c, 3, 2000);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
TEST_ASSERT_EQUAL_INT(3, c.n);
|
||
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, c.pending_count);
|
||
|
||
for (int i = 0; i < 3; i++) {
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[i].state.role);
|
||
TEST_ASSERT_EQUAL_UINT64(0, c.nodes[i].state.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, c.nodes[i].state.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(i, c.nodes[i].idx);
|
||
TEST_ASSERT_EQUAL_INT(0, c.nodes[i].partition);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* test_sim_trace_buf
|
||
*
|
||
* After raft_sim_init, calling raft_start_election on node 0 (which has
|
||
* on_trace wired) must append at least one event to node 0's trace_buf.
|
||
*/
|
||
static void test_sim_trace_buf(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
int rc = raft_sim_init(&c, 3, 2000);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
/* trace_buf must start empty */
|
||
TEST_ASSERT_EQUAL_INT(0, c.nodes[0].trace_len);
|
||
|
||
/* Start election on node 0 — on_trace wired in raft_sim_init will write to trace_buf */
|
||
raft_start_election(&c.nodes[0].state);
|
||
|
||
/* At least one trace event must have been written */
|
||
TEST_ASSERT_GREATER_THAN_INT(0, c.nodes[0].trace_len);
|
||
}
|
||
|
||
/*
|
||
* test_sim_3node_election_one_leader (task 4.2)
|
||
*
|
||
* Node 0 starts the election alone (priority=0, highest priority).
|
||
* Nodes 1 and 2 have not yet voted, so they grant node 0's request.
|
||
* After delivering pending RPCs, node 0 reaches quorum and becomes LEADER.
|
||
*
|
||
* Steps:
|
||
* 1. raft_sim_init(c, 3, 2000)
|
||
* 2. raft_start_election only for node 0 → buffers 2 vote-request RPCs
|
||
* 3. raft_sim_deliver_pending(c) → nodes 1 and 2 grant; node 0 wins
|
||
* 4. Assert c->leader_count == 1 (exactly one LEADER, no split brain)
|
||
* 5. Assert node 0 is RAFT_LEADER, nodes 1 and 2 are RAFT_FOLLOWER
|
||
*/
|
||
static void test_sim_3node_election_one_leader(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
int rc = raft_sim_init(&c, 3, 2000);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
/* Only node 0 starts an election (highest priority) */
|
||
raft_start_election(&c.nodes[0].state);
|
||
|
||
/* 2 RPCs buffered (to nodes 1 and 2) */
|
||
TEST_ASSERT_EQUAL_INT(2, c.pending_count);
|
||
|
||
/* Deliver: nodes 1 and 2 have not voted yet → both grant node 0 */
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Exactly one leader must exist */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
|
||
/* Node 0 must be the leader */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Nodes 1 and 2 are followers */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
|
||
}
|
||
|
||
/*
|
||
* test_sim_start_all_elections_no_winner (task 4.2)
|
||
*
|
||
* All 3 nodes start elections simultaneously at the same term.
|
||
* Each node votes for itself; all peer requests are denied (already voted).
|
||
* No node reaches quorum — leader_count remains 0.
|
||
*
|
||
* This demonstrates that simultaneous same-term elections produce no winner
|
||
* and require a timeout + retry cycle to resolve.
|
||
*/
|
||
static void test_sim_start_all_elections_no_winner(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
int rc = raft_sim_init(&c, 3, 2000);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
/* All nodes start elections at term 1 — each votes for itself */
|
||
raft_sim_start_all_elections(&c);
|
||
|
||
/* 6 RPCs buffered: each of 3 nodes sends to 2 peers */
|
||
TEST_ASSERT_EQUAL_INT(6, c.pending_count);
|
||
|
||
/* Deliver: every node has voted_for=self, so all peer requests are denied */
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* No leader — all denied each other */
|
||
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
|
||
|
||
/* All nodes have stepped back to FOLLOWER after all-replied-no-quorum */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[0].state.role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
|
||
}
|
||
|
||
/*
|
||
* test_sim_partition_no_quorum (task 4.2)
|
||
*
|
||
* Node 2 is isolated before it starts an election.
|
||
* Its vote-request RPCs are dropped during delivery (partition flag set).
|
||
* Node 2 receives no grants and remains CANDIDATE; leader_count stays 0.
|
||
*
|
||
* Steps:
|
||
* 1. raft_sim_init(c, 3, 2000)
|
||
* 2. raft_sim_partition(c, 2, 1) — isolate node 2
|
||
* 3. raft_start_election(&c->nodes[2]) — node 2 starts election
|
||
* 4. raft_sim_deliver_pending(c) — RPCs to/from node 2 are dropped
|
||
* 5. Assert node 2 is still CANDIDATE (no quorum reached)
|
||
* 6. Assert c->leader_count == 0
|
||
*/
|
||
static void test_sim_partition_no_quorum(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
int rc = raft_sim_init(&c, 3, 2000);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
/* Isolate node 2 */
|
||
raft_sim_partition(&c, 2, 1);
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].partition);
|
||
|
||
/* Node 2 starts an election — becomes CANDIDATE and buffers RPCs */
|
||
raft_start_election(&c.nodes[2].state);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[2].state.role);
|
||
|
||
/* Deliver: node 2 is partitioned → all its RPCs are dropped */
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Node 2 never received any grants — still CANDIDATE */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[2].state.role);
|
||
|
||
/* No leader elected */
|
||
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_sim_fire_timeout_steps_down (task 4.2)
|
||
*
|
||
* Node 0 starts an election (becomes CANDIDATE).
|
||
* raft_sim_fire_timeout fires the election timeout for node 0.
|
||
* Node 0 must step down to FOLLOWER.
|
||
*/
|
||
static void test_sim_fire_timeout_steps_down(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Start election — node 0 becomes CANDIDATE */
|
||
raft_start_election(&c.nodes[0].state);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[0].state.role);
|
||
|
||
/* Fire timeout — no quorum was reached */
|
||
raft_sim_fire_timeout(&c, 0);
|
||
|
||
/* Node 0 must step down */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[0].state.role);
|
||
}
|
||
|
||
/*
|
||
* test_sim_deliver_append_entries_replicates (task 8.2)
|
||
*
|
||
* Delivery smoke test for raft_sim_deliver_append_entries: a leader appends one
|
||
* entry and replicates it to a connected follower; a partitioned follower
|
||
* receives nothing.
|
||
*
|
||
* Steps:
|
||
* 1. raft_sim_init(c, 3, 2000); elect node 0 leader.
|
||
* 2. Partition node 2 (so it must not receive the entry).
|
||
* 3. Leader (node 0) raft_log_append one entry → lands in node 0's log[0].
|
||
* 4. raft_send_append_entries to node 1 (connected) and node 2 (partitioned)
|
||
* → buffers two AEs.
|
||
* 5. raft_sim_deliver_append_entries(c) routes them.
|
||
* 6. Assert node 1's in-memory log now holds the entry (log_count >= 1, term set).
|
||
* 7. Assert node 2 (partitioned) received nothing (log_count == 0).
|
||
*/
|
||
static void test_sim_deliver_append_entries_replicates(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Elect node 0 leader. */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Isolate node 2 — it must receive nothing. */
|
||
raft_sim_partition(&c, 2, 1);
|
||
|
||
/* Leader appends one client entry; persisted into node 0's in-memory log. */
|
||
unsigned char payload[4] = { 0xDE, 0xAD, 0xBE, 0xEF };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64(1, out_index);
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[0].log_count);
|
||
|
||
/* Send AppendEntries to connected (1) and partitioned (2) followers. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 2, /*leader_commit=*/0));
|
||
|
||
/* Deliver: node 1 receives + replies; node 2 is dropped (partitioned). */
|
||
raft_sim_deliver_append_entries(&c);
|
||
|
||
/* Connected follower replicated the entry — its term must equal the LEADER'S
|
||
* PERSISTED entry term (node 0's log[0]), the authoritative send-time term,
|
||
* not the leader's live state term (which only coincides here). */
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
|
||
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[0].term, c.nodes[1].log[0].term);
|
||
|
||
/* Partitioned follower received nothing. */
|
||
TEST_ASSERT_EQUAL_INT(0, c.nodes[2].log_count);
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_log_matching_pass_on_converged (task 8.3)
|
||
*
|
||
* Log Matching helper must return PASS (1) when two nodes hold identical logs.
|
||
* Build a 3-node cluster, elect node 0, append two entries, replicate both to
|
||
* node 1; assert raft_sim_check_log_matching == 1 and node 1's prefix matches
|
||
* node 0's byte-for-byte.
|
||
*/
|
||
static void test_sim_check_log_matching_pass_on_converged(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Append two client entries on the leader and replicate each to node 1.
|
||
* One AppendEntries carries at most one entry, so replicate index by index. */
|
||
unsigned char p1[3] = { 0x01, 0x02, 0x03 };
|
||
unsigned char p2[3] = { 0x04, 0x05, 0x06 };
|
||
uint64_t out_index = 0;
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, p1, sizeof(p1), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64(1, out_index);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
|
||
raft_sim_deliver_append_entries(&c);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, p2, sizeof(p2), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64(2, out_index);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
|
||
raft_sim_deliver_append_entries(&c);
|
||
|
||
/* Node 1 holds the leader's prefix byte-for-byte. */
|
||
TEST_ASSERT_EQUAL_INT(2, c.nodes[0].log_count);
|
||
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
|
||
|
||
/* Log Matching holds across the cluster. */
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_log_matching_fail_on_divergence (task 8.3)
|
||
*
|
||
* Log Matching helper must return FAIL (0) when two nodes share the same
|
||
* (index, term) at some i but their prefixes differ. Hand-craft node 0 and
|
||
* node 1 so that index 2 carries the SAME term but index 1 differs in bytes —
|
||
* a Log Matching violation (equal (i,term) but non-identical prefix through i).
|
||
*/
|
||
static void test_sim_check_log_matching_fail_on_divergence(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Hand-craft node 0: index1=(term 5, "AAA"), index2=(term 7, "XXX"). */
|
||
c.nodes[0].log[0].term = 5;
|
||
c.nodes[0].log[0].bytes[0] = 'A';
|
||
c.nodes[0].log[0].bytes[1] = 'A';
|
||
c.nodes[0].log[0].bytes[2] = 'A';
|
||
c.nodes[0].log[0].len = 3;
|
||
c.nodes[0].log[1].term = 7;
|
||
c.nodes[0].log[1].bytes[0] = 'X';
|
||
c.nodes[0].log[1].bytes[1] = 'X';
|
||
c.nodes[0].log[1].bytes[2] = 'X';
|
||
c.nodes[0].log[1].len = 3;
|
||
c.nodes[0].log_count = 2;
|
||
|
||
/* Hand-craft node 1: SAME (index2, term 7) but a DIFFERENT index1 prefix
|
||
* (term 5 but bytes "BBB"). This must be flagged: equal (i=2, term=7) yet
|
||
* the prefix through 2 is not byte-identical. */
|
||
c.nodes[1].log[0].term = 5;
|
||
c.nodes[1].log[0].bytes[0] = 'B';
|
||
c.nodes[1].log[0].bytes[1] = 'B';
|
||
c.nodes[1].log[0].bytes[2] = 'B';
|
||
c.nodes[1].log[0].len = 3;
|
||
c.nodes[1].log[1].term = 7;
|
||
c.nodes[1].log[1].bytes[0] = 'X';
|
||
c.nodes[1].log[1].bytes[1] = 'X';
|
||
c.nodes[1].log[1].bytes[2] = 'X';
|
||
c.nodes[1].log[1].len = 3;
|
||
c.nodes[1].log_count = 2;
|
||
|
||
/* Same (index2, term7) with divergent index1 ⇒ Log Matching violated. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_3_2_log_matching_shared_idx_term_requires_identical_prefix (task 3.2)
|
||
*
|
||
* Spec R3.2 (Log Matching): "for any two nodes whose logs contain an entry at
|
||
* the same index with the same term, all entries at and before that index are
|
||
* identical (same term and value) across those two logs". This test exercises
|
||
* raft_sim_check_log_matching over hand-built clusters in BOTH directions, with
|
||
* the shared (idx, term) at a LATER index (3) and the divergence at an EARLIER
|
||
* index (1) — a distinct shape from the task-8.3 tests (which share index 2 and
|
||
* diverge at index 1) so the spec's R3.2 traceability is explicit.
|
||
*
|
||
* It is a pure checker unit test: roles/terms/bytes are set directly (no
|
||
* protocol drive) per Req 3.6 (observable per-node log state only). The checker
|
||
* pre-exists (raft-log-replication); this test confirms — and guards — that it
|
||
* correctly flags an earlier-prefix divergence and passes identical prefixes.
|
||
*/
|
||
static void test_sim_3_2_log_matching_shared_idx_term_requires_identical_prefix(void)
|
||
{
|
||
/* ---- Direction A: shared (idx=3, term=9), differ at earlier idx=1 ⇒ FAIL.
|
||
* Both nodes carry the SAME (term, value) at indices 2 and 3, and the SAME
|
||
* term at index 1, but index-1 VALUE differs. Because index 3 is shared with
|
||
* the same term, the prefix 1..3 must be identical — index 1 breaks it. */
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* node 0: idx1=(t3,"AAA") idx2=(t6,"MMM") idx3=(t9,"ZZZ") */
|
||
c.nodes[0].log[0].term = 3; memcpy(c.nodes[0].log[0].bytes, "AAA", 3); c.nodes[0].log[0].len = 3;
|
||
c.nodes[0].log[1].term = 6; memcpy(c.nodes[0].log[1].bytes, "MMM", 3); c.nodes[0].log[1].len = 3;
|
||
c.nodes[0].log[2].term = 9; memcpy(c.nodes[0].log[2].bytes, "ZZZ", 3); c.nodes[0].log[2].len = 3;
|
||
c.nodes[0].log_count = 3;
|
||
|
||
/* node 1: SAME (idx3,t9) and (idx2,t6) but idx1 term matches with a
|
||
* DIFFERENT value ("CCC") — earlier-prefix divergence under a shared tip. */
|
||
c.nodes[1].log[0].term = 3; memcpy(c.nodes[1].log[0].bytes, "CCC", 3); c.nodes[1].log[0].len = 3;
|
||
c.nodes[1].log[1].term = 6; memcpy(c.nodes[1].log[1].bytes, "MMM", 3); c.nodes[1].log[1].len = 3;
|
||
c.nodes[1].log[2].term = 9; memcpy(c.nodes[1].log[2].bytes, "ZZZ", 3); c.nodes[1].log[2].len = 3;
|
||
c.nodes[1].log_count = 3;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/* ---- Direction B: identical prefixes ⇒ PASS. node 1 is a strict prefix of
|
||
* node 0 (idx 1..2 byte-identical); node 2 holds the full identical prefix. */
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
c.nodes[0].log[0].term = 3; memcpy(c.nodes[0].log[0].bytes, "AAA", 3); c.nodes[0].log[0].len = 3;
|
||
c.nodes[0].log[1].term = 6; memcpy(c.nodes[0].log[1].bytes, "MMM", 3); c.nodes[0].log[1].len = 3;
|
||
c.nodes[0].log[2].term = 9; memcpy(c.nodes[0].log[2].bytes, "ZZZ", 3); c.nodes[0].log[2].len = 3;
|
||
c.nodes[0].log_count = 3;
|
||
|
||
/* node 1: shorter but byte-identical prefix (idx 1..2). */
|
||
c.nodes[1].log[0].term = 3; memcpy(c.nodes[1].log[0].bytes, "AAA", 3); c.nodes[1].log[0].len = 3;
|
||
c.nodes[1].log[1].term = 6; memcpy(c.nodes[1].log[1].bytes, "MMM", 3); c.nodes[1].log[1].len = 3;
|
||
c.nodes[1].log_count = 2;
|
||
|
||
/* node 2: full identical prefix (idx 1..3). */
|
||
c.nodes[2].log[0].term = 3; memcpy(c.nodes[2].log[0].bytes, "AAA", 3); c.nodes[2].log[0].len = 3;
|
||
c.nodes[2].log[1].term = 6; memcpy(c.nodes[2].log[1].bytes, "MMM", 3); c.nodes[2].log[1].len = 3;
|
||
c.nodes[2].log[2].term = 9; memcpy(c.nodes[2].log[2].bytes, "ZZZ", 3); c.nodes[2].log[2].len = 3;
|
||
c.nodes[2].log_count = 3;
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
}
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_election_safety_fail_on_two_leaders (task 3.1)
|
||
*
|
||
* Election Safety helper must return FAIL (0) when two distinct nodes hold the
|
||
* LEADER role at the SAME term. Electing two leaders at one term is impossible
|
||
* through the protocol, so hand-set role/term directly on two nodes (this is a
|
||
* checker unit test over a hand-built cluster). Both node 0 and node 1 are
|
||
* LEADER at term 7 ⇒ violation.
|
||
*/
|
||
static void test_sim_check_election_safety_fail_on_two_leaders(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Two LEADERs at the SAME term ⇒ Election Safety violated. */
|
||
c.nodes[0].state.role = RAFT_LEADER;
|
||
c.nodes[0].state.term = 7;
|
||
c.nodes[1].state.role = RAFT_LEADER;
|
||
c.nodes[1].state.term = 7;
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.term = 7;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_election_safety(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_election_safety_pass_on_single_leader (task 3.1)
|
||
*
|
||
* Election Safety helper must return PASS (1) when there is at most one LEADER
|
||
* per term. Two scenarios both hold:
|
||
* (a) one LEADER at term T, others FOLLOWER;
|
||
* (b) two LEADERs at DIFFERENT terms (a leader and a stale ex-leader that has
|
||
* not yet stepped down) — distinct terms never collide.
|
||
*/
|
||
static void test_sim_check_election_safety_pass_on_single_leader(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* (a) exactly one LEADER at term 7, the rest FOLLOWER ⇒ holds. */
|
||
c.nodes[0].state.role = RAFT_LEADER;
|
||
c.nodes[0].state.term = 7;
|
||
c.nodes[1].state.role = RAFT_FOLLOWER;
|
||
c.nodes[1].state.term = 7;
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.term = 7;
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_election_safety(&c));
|
||
|
||
/* (b) two LEADERs but at DIFFERENT terms ⇒ still holds (no same-term pair). */
|
||
c.nodes[1].state.role = RAFT_LEADER;
|
||
c.nodes[1].state.term = 6;
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_election_safety(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_leader_completeness_fail_on_missing_committed (task 3.3)
|
||
*
|
||
* Leader Completeness: an entry committed in term t must be present (same index,
|
||
* same term) in the log of every leader of a higher term. Hand-build a cluster
|
||
* where node B has committed (index=2, term=3) but the current higher-term leader
|
||
* (node A, LEADER at term 5) is MISSING that index ⇒ the checker must FAIL (0).
|
||
* (Hand-set role/term/log/commit_index directly — this is a checker unit test over
|
||
* a hand-built cluster, not a protocol run.)
|
||
*/
|
||
static void test_sim_check_leader_completeness_fail_on_missing_committed(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Node B (idx 1): committed prefix 1..2, with entry 2 carrying term 3. */
|
||
c.nodes[1].log[0].term = 1;
|
||
c.nodes[1].log[1].term = 3;
|
||
c.nodes[1].log_count = 2;
|
||
c.nodes[1].commit_index = 2; /* (index=2, term=3) is committed */
|
||
|
||
/* Node A (idx 0): current LEADER at the HIGHER term 5, but its log is SHORT
|
||
* (only index 1) ⇒ it is MISSING the committed index 2 ⇒ violation. */
|
||
c.nodes[0].state.role = RAFT_LEADER;
|
||
c.nodes[0].state.term = 5;
|
||
c.nodes[0].log[0].term = 1;
|
||
c.nodes[0].log_count = 1;
|
||
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.term = 5;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_leader_completeness(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_leader_completeness_pass_when_present (task 3.3)
|
||
*
|
||
* Leader Completeness holds when every higher-term leader's log contains every
|
||
* lower-term committed entry at the same (index, term). Two scenarios pass:
|
||
* (a) the committed (index=2, term=3) entry IS present in higher-term leader A;
|
||
* (b) a clean cluster with no current leaders trivially holds.
|
||
*/
|
||
static void test_sim_check_leader_completeness_pass_when_present(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Node B (idx 1): committed prefix 1..2, entry 2 carries term 3. */
|
||
c.nodes[1].log[0].term = 1;
|
||
c.nodes[1].log[1].term = 3;
|
||
c.nodes[1].log_count = 2;
|
||
c.nodes[1].commit_index = 2;
|
||
|
||
/* (a) Node A (idx 0): LEADER at higher term 5 and DOES contain index 2 at
|
||
* term 3 (and index 1) ⇒ Leader Completeness holds. */
|
||
c.nodes[0].state.role = RAFT_LEADER;
|
||
c.nodes[0].state.term = 5;
|
||
c.nodes[0].log[0].term = 1;
|
||
c.nodes[0].log[1].term = 3;
|
||
c.nodes[0].log_count = 2;
|
||
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.term = 5;
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_leader_completeness(&c));
|
||
|
||
/* (b) no current leaders at all ⇒ trivially holds (nothing to bind). */
|
||
c.nodes[0].state.role = RAFT_FOLLOWER;
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_leader_completeness(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_state_machine_safety_fail_on_divergence (task 3.4; Req 3.4, 3.6)
|
||
*
|
||
* State Machine Safety: no two nodes may have applied DIFFERENT values at the same
|
||
* (1-based) log index within their common applied prefix (1..min(commit_index)).
|
||
* Hand-build two nodes that both committed index 2 but applied DIFFERENT values at
|
||
* index 2 ⇒ the checker must FAIL (0). The applied extent per node is commit_index
|
||
* (sim_conf_on_commit_advanced fills applied[1..commit_index]); applied[k-1] is the
|
||
* value applied at 1-based index k. (Hand-set applied[]/commit_index directly — a
|
||
* checker unit test over a hand-built cluster, not a protocol run.)
|
||
*/
|
||
static void test_sim_check_state_machine_safety_fail_on_divergence(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Both node 0 and node 1 committed (applied) indices 1..2. They AGREE at
|
||
* index 1 (value 10) but DISAGREE at index 2 (30 vs 99) ⇒ violation at a
|
||
* shared applied index. */
|
||
c.nodes[0].commit_index = 2;
|
||
c.nodes[0].applied[0] = 10;
|
||
c.nodes[0].applied[1] = 30;
|
||
|
||
c.nodes[1].commit_index = 2;
|
||
c.nodes[1].applied[0] = 10;
|
||
c.nodes[1].applied[1] = 99; /* differs from node 0 at index 2 */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_state_machine_safety(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_state_machine_safety_pass_on_agreement (task 3.4; Req 3.4, 3.6)
|
||
*
|
||
* State Machine Safety holds when every pair of nodes agrees on applied values
|
||
* across their COMMON applied prefix. Two scenarios pass:
|
||
* (a) two nodes with identical applied[] over equal commit extents (both [10,20]);
|
||
* (b) nodes with DIFFERENT commit extents but identical values over the SHARED
|
||
* prefix (node0 [10,20,30] @commit 3 vs node1 [10,20] @commit 2 ⇒ shared
|
||
* prefix 1..2 is identical ⇒ holds; index 3 is not applied by node1).
|
||
*/
|
||
static void test_sim_check_state_machine_safety_pass_on_agreement(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* (a) Identical applied[] over equal commit extents. */
|
||
c.nodes[0].commit_index = 2;
|
||
c.nodes[0].applied[0] = 10;
|
||
c.nodes[0].applied[1] = 20;
|
||
|
||
c.nodes[1].commit_index = 2;
|
||
c.nodes[1].applied[0] = 10;
|
||
c.nodes[1].applied[1] = 20;
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_state_machine_safety(&c));
|
||
|
||
/* (b) Different commit extents, identical over the shared prefix 1..2. Node 0
|
||
* has applied one extra index (3 -> 30) that node 1 has not committed; the
|
||
* shared prefix still agrees ⇒ holds. */
|
||
c.nodes[0].commit_index = 3;
|
||
c.nodes[0].applied[2] = 30;
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_state_machine_safety(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_all_first_failure_is_election_safety (task 3.5; Req 3.5)
|
||
*
|
||
* raft_sim_check_all evaluates the four invariants in a FIXED ORDER —
|
||
* (1) Election Safety, (2) Log Matching, (3) Leader Completeness,
|
||
* (4) State Machine Safety — and returns the identifier of the FIRST one that
|
||
* is violated, short-circuiting before later checkers run. Hand-build a cluster
|
||
* that violates TWO invariants: Election Safety (earliest in order) AND State
|
||
* Machine Safety (latest). The composer must report ELECTION_SAFETY (the first
|
||
* in fixed order), NOT the later State Machine Safety violation — proving
|
||
* deterministic first-failure ordering.
|
||
*/
|
||
static void test_sim_check_all_first_failure_is_election_safety(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Violation 1 (Election Safety): two LEADERs at the SAME term 7. */
|
||
c.nodes[0].state.role = RAFT_LEADER;
|
||
c.nodes[0].state.term = 7;
|
||
c.nodes[1].state.role = RAFT_LEADER;
|
||
c.nodes[1].state.term = 7;
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.term = 7;
|
||
|
||
/* Violation 2 (State Machine Safety): nodes 0 and 1 applied DIFFERENT
|
||
* values at the shared committed index 1. */
|
||
c.nodes[0].commit_index = 1;
|
||
c.nodes[0].applied[0] = 10;
|
||
c.nodes[1].commit_index = 1;
|
||
c.nodes[1].applied[0] = 99;
|
||
|
||
/* Election Safety is first in the fixed order ⇒ the composer reports it and
|
||
* never reaches the State Machine Safety check. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_INV_ELECTION_SAFETY, raft_sim_check_all(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_all_reaches_later_invariant (task 3.5; Req 3.5)
|
||
*
|
||
* When all earlier invariants hold, the composer must reach and report a
|
||
* later-in-order violation. Hand-build a cluster that violates ONLY State
|
||
* Machine Safety (the LAST checker) — Election Safety, Log Matching, and Leader
|
||
* Completeness all hold. The composer must return STATE_MACHINE_SAFETY, proving
|
||
* the later checkers are actually evaluated once the earlier ones pass.
|
||
*/
|
||
static void test_sim_check_all_reaches_later_invariant(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* No two same-term leaders (Election Safety holds): no leaders at all. */
|
||
/* Logs empty ⇒ Log Matching trivially holds; no leaders ⇒ Leader
|
||
* Completeness trivially holds. Only divergent applied[] at shared index 1
|
||
* violates State Machine Safety. */
|
||
c.nodes[0].commit_index = 1;
|
||
c.nodes[0].applied[0] = 10;
|
||
c.nodes[1].commit_index = 1;
|
||
c.nodes[1].applied[0] = 99; /* differs from node 0 at index 1 */
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_INV_STATE_MACHINE_SAFETY,
|
||
raft_sim_check_all(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_check_all_clean_returns_none (task 3.5; Req 3.5)
|
||
*
|
||
* A cluster with no violations of any invariant ⇒ raft_sim_check_all returns
|
||
* RAFT_SIM_INV_NONE (0). A freshly-initialised cluster (no leaders, empty logs,
|
||
* nothing committed) satisfies all four invariants trivially.
|
||
*/
|
||
static void test_sim_check_all_clean_returns_none(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* One clean LEADER, agreeing logs and applied prefix ⇒ everything holds. */
|
||
c.nodes[0].state.role = RAFT_LEADER;
|
||
c.nodes[0].state.term = 5;
|
||
c.nodes[0].log[0].term = 5;
|
||
c.nodes[0].log_count = 1;
|
||
c.nodes[0].commit_index = 1;
|
||
c.nodes[0].applied[0] = 42;
|
||
|
||
c.nodes[1].state.role = RAFT_FOLLOWER;
|
||
c.nodes[1].state.term = 5;
|
||
c.nodes[1].log[0].term = 5;
|
||
c.nodes[1].log_count = 1;
|
||
c.nodes[1].commit_index = 1;
|
||
c.nodes[1].applied[0] = 42;
|
||
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.term = 5;
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_INV_NONE, raft_sim_check_all(&c));
|
||
}
|
||
|
||
/* ---------- Group: Simulator invariant tests (tasks 9.1-9.4) ----------
|
||
*
|
||
* These are observation-based invariant tests. They drive the COMPLETE
|
||
* machinery (raft_log_append / raft_send_append_entries /
|
||
* raft_sim_deliver_append_entries / raft_on_append_entries_reply) and assert
|
||
* Raft Log Matching (raft_sim_check_log_matching) plus the divergent-suffix
|
||
* truncation, drop/dup/reorder convergence, and chaotic-churn (bug-851)
|
||
* properties from requirements 8.1, 8.3, 8.4, 8.5, 8.6.
|
||
*
|
||
* Each test carries INTERMEDIATE assertions that would FAIL if convergence
|
||
* did NOT actually happen (e.g. follower log_count, exact entry terms,
|
||
* nextIndex back-off), so a trivially-passing run is impossible: the
|
||
* end-to-end raft_sim_check_log_matching == 1 is only reachable after the
|
||
* replicate/catch-up/truncate/term-adoption work is genuinely observed.
|
||
*/
|
||
|
||
/* Replicate the leader's single entry at the leader's CURRENT next_index for
|
||
* `peer` and route it synchronously. Returns the follower's resulting reply via
|
||
* the sim's reply path (already applied to the leader's per-peer progress). */
|
||
static void sim_replicate_one(raft_sim_cluster_t *c, int leader, int peer,
|
||
uint64_t leader_commit)
|
||
{
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c->nodes[leader].state, peer, leader_commit));
|
||
raft_sim_deliver_append_entries(c);
|
||
}
|
||
|
||
/*
|
||
* test_sim_9_1_replication_and_partition_catch_up (task 9.1; Req 8.1, 8.3, 8.4)
|
||
*
|
||
* Leader appends 3 entries, replicates to a quorum, asserts Log Matching.
|
||
* Then isolates a follower across MORE appends, heals it, and drives catch-up:
|
||
* the leader's nextIndex for the lagging follower walks BACK (failure replies
|
||
* decrement next_index), the follower catches up entry-by-entry, and Log
|
||
* Matching holds end-to-end.
|
||
*
|
||
* Load-bearing intermediate assertions:
|
||
* - after the initial 3-entry replication, node 1 AND node 2 hold log_count 3
|
||
* (if delivery were skipped these would be 0 — the assertion is real).
|
||
* - after isolation + 2 more appends, node 2 is STILL at log_count 3 (proves
|
||
* the partition genuinely blocked replication) while leader is at 5.
|
||
* - the first post-heal AppendEntries (prev_log_index=4) is REJECTED, forcing
|
||
* next_index[2] to decrement (catch-up back-off is exercised, not bypassed).
|
||
* - node 2 converges to log_count 5 and Log Matching holds.
|
||
*/
|
||
static void test_sim_9_1_replication_and_partition_catch_up(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Elect node 0 leader. */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Append 3 entries and replicate each to BOTH followers (a quorum). */
|
||
for (int e = 0; e < 3; e++) {
|
||
unsigned char payload[2] = { (unsigned char)('A' + e), 0x00 };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
sim_replicate_one(&c, 0, 2, 0);
|
||
}
|
||
|
||
/* Intermediate: both followers replicated all 3 (delivery actually happened). */
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[2].log_count);
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
|
||
/* After replicating 1..3 to node 2, the leader believes next_index[2] == 4. */
|
||
TEST_ASSERT_EQUAL_UINT64(4, c.nodes[0].state.next_index[2]);
|
||
|
||
/* Isolate node 2; append 2 MORE entries and replicate only to node 1.
|
||
* Sends to node 2 are dropped (partitioned), so next_index[2] stays 4. */
|
||
raft_sim_partition(&c, 2, 1);
|
||
for (int e = 3; e < 5; e++) {
|
||
unsigned char payload[2] = { (unsigned char)('A' + e), 0x00 };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
/* Attempt to send to node 2 as well — dropped because partitioned. */
|
||
sim_replicate_one(&c, 0, 2, 0);
|
||
}
|
||
|
||
/* Intermediate: the partition genuinely blocked node 2 (still 3, not 5). */
|
||
TEST_ASSERT_EQUAL_INT(5, c.nodes[0].log_count);
|
||
TEST_ASSERT_EQUAL_INT(5, c.nodes[1].log_count);
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[2].log_count);
|
||
|
||
/* Simulate node 2 having LOST entries 2..3 while away (e.g. a crash that
|
||
* rolled back its tail to 1) so its real log no longer matches the leader's
|
||
* stale next_index[2] == 4. This forces the AppendEntries consistency check
|
||
* to walk next_index[2] BACK until prev_log_index matches node 2's tail. */
|
||
raft_sim_partition(&c, 2, 0);
|
||
c.nodes[2].cbs.on_truncate_from(&c.nodes[2], 2); /* drop indices 2..end */
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].log_count);
|
||
|
||
uint64_t ni_before = c.nodes[0].state.next_index[2];
|
||
/* The leader still believes node 2 is at index 3 (next_index 4), past its
|
||
* real tail (1) — so the first AE prev_log_index = 3 must be rejected. */
|
||
TEST_ASSERT_TRUE(ni_before > (uint64_t)(c.nodes[2].log_count + 1));
|
||
|
||
/* One round: rejected (prev_log_index beyond node 2's log) ⇒ next_index-- . */
|
||
sim_replicate_one(&c, 0, 2, 0);
|
||
TEST_ASSERT_TRUE(c.nodes[0].state.next_index[2] < ni_before);
|
||
|
||
/* Continue driving rounds until node 2 catches up (bounded loop). Each round
|
||
* either backs off (reject) or appends one entry (accept), so it converges. */
|
||
int rounds = 0;
|
||
while (c.nodes[2].log_count < c.nodes[0].log_count && rounds < 64) {
|
||
sim_replicate_one(&c, 0, 2, 0);
|
||
rounds++;
|
||
}
|
||
|
||
/* Converged: node 2 holds the full prefix; Log Matching holds end-to-end. */
|
||
TEST_ASSERT_EQUAL_INT(5, c.nodes[2].log_count);
|
||
for (int i = 0; i < 5; i++)
|
||
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[2].log[i].term);
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_9_2_divergent_suffix_truncation (task 9.2; Req 8.5, closes G3)
|
||
*
|
||
* Give a follower a manufactured divergent tail (same indices, DIFFERENT term),
|
||
* replicate from a higher-term leader, and assert the follower truncates the
|
||
* divergent suffix (on_truncate_from via the conflict branch) and converges to
|
||
* the leader's prefix. The follower's post-converge prefix must equal the
|
||
* leader's byte-for-byte; Log Matching holds.
|
||
*
|
||
* Load-bearing intermediate assertions:
|
||
* - pre-replication, node 1 holds the WRONG (term-2) entries at indices 1..3,
|
||
* differing from node 0's term-3 entries (so a no-op delivery would FAIL
|
||
* the final byte-equality / Log Matching check).
|
||
* - the conflict at index 1 triggers a truncation back-off, and node 1's
|
||
* final terms equal node 0's (proves the divergent suffix was deleted, not
|
||
* merely overwritten in place across a mismatching prefix).
|
||
*/
|
||
static void test_sim_9_2_divergent_suffix_truncation(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Elect node 0 leader, then advance its term to 3 (strictly higher than the
|
||
* follower's manufactured divergent term 2). raft_log_append stamps each new
|
||
* entry with s->term, so setting the term before appending makes the leader's
|
||
* entries carry term 3. next_index/match_index were initialized at
|
||
* become_leader and are term-independent, so they remain valid. */
|
||
raft_start_election(&c.nodes[0].state); /* term -> 1 */
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
c.nodes[0].state.term = 3;
|
||
uint64_t leader_term = c.nodes[0].state.term;
|
||
TEST_ASSERT_TRUE(leader_term >= 3);
|
||
|
||
unsigned char lp[3][3] = {
|
||
{ 'L', '0', 0 }, { 'L', '1', 0 }, { 'L', '2', 0 } };
|
||
for (int e = 0; e < 3; e++) {
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, lp[e], 2, &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
|
||
}
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
|
||
for (int i = 0; i < 3; i++)
|
||
TEST_ASSERT_EQUAL_UINT64(leader_term, c.nodes[0].log[i].term);
|
||
|
||
/* Manufacture node 1's DIVERGENT tail: same indices 1..3, term 2, "X" bytes.
|
||
* Persist directly into the sim log (same indices, lower/different term). */
|
||
for (int i = 0; i < 3; i++) {
|
||
c.nodes[1].log[i].term = 2;
|
||
c.nodes[1].log[i].bytes[0] = 'X';
|
||
c.nodes[1].log[i].bytes[1] = (unsigned char)('0' + i);
|
||
c.nodes[1].log[i].len = 2;
|
||
}
|
||
c.nodes[1].log_count = 3;
|
||
/* Make node 1 a follower whose term is at most the leader's (so it accepts). */
|
||
raft_on_append_entries_request(&c.nodes[1].state, 0, leader_term,
|
||
0, 0, 0, NULL, 0, 0, 0, &(raft_append_result_t){0});
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
|
||
|
||
/* Intermediate: node 1 currently DIVERGES from node 0 at every index. */
|
||
TEST_ASSERT_TRUE(c.nodes[1].log[0].term != c.nodes[0].log[0].term);
|
||
|
||
/* Replicate from the higher-term leader. next_index[1] starts at Len(log)+1
|
||
* = 4; the leader walks back, the conflict branch truncates the divergent
|
||
* suffix, then appends the leader's entries. Drive bounded rounds. */
|
||
int rounds = 0;
|
||
while (rounds < 64) {
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
rounds++;
|
||
if (c.nodes[1].log_count == c.nodes[0].log_count) {
|
||
int all_match = 1;
|
||
for (int i = 0; i < c.nodes[0].log_count; i++)
|
||
if (c.nodes[1].log[i].term != c.nodes[0].log[i].term)
|
||
all_match = 0;
|
||
if (all_match)
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Converged: node 1's prefix now EQUALS node 0's byte-for-byte (the divergent
|
||
* term-2 suffix was truncated and replaced with the leader's term-N prefix). */
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
|
||
for (int i = 0; i < 3; i++) {
|
||
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[1].log[i].term);
|
||
TEST_ASSERT_EQUAL_size_t(c.nodes[0].log[i].len, c.nodes[1].log[i].len);
|
||
TEST_ASSERT_EQUAL_MEMORY(c.nodes[0].log[i].bytes, c.nodes[1].log[i].bytes,
|
||
c.nodes[0].log[i].len);
|
||
}
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_9_3_drop_dup_reorder_convergence (task 9.3; Req 8.3)
|
||
*
|
||
* Drop, duplicate, and reorder AppendEntries in the sim queue and assert
|
||
* idempotent convergence: a duplicated AE is a no-op via the already-have
|
||
* branch, a dropped AE is recovered by a later round, and reordering does not
|
||
* corrupt the log. Log Matching holds after the faults clear.
|
||
*
|
||
* Load-bearing intermediate assertions:
|
||
* - after the deliberately-DROPPED round, node 1 is still missing the entry
|
||
* (proves the drop was real, not silently delivered).
|
||
* - after delivering a DUPLICATE of an already-applied AE, node 1's log_count
|
||
* does NOT grow (idempotent already-have branch).
|
||
* - final Log Matching holds and node 1 holds the full prefix.
|
||
*/
|
||
static void test_sim_9_3_drop_dup_reorder_convergence(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Append 3 entries on the leader. */
|
||
for (int e = 0; e < 3; e++) {
|
||
unsigned char payload[2] = { (unsigned char)('a' + e), 0x00 };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
|
||
}
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
|
||
|
||
/* DROP: buffer an AE for entry 1 to node 1, then clear the queue WITHOUT
|
||
* delivering (simulate a dropped message). */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
|
||
TEST_ASSERT_EQUAL_INT(1, c.ae_pending_count);
|
||
c.ae_pending_count = 0; /* DROP the buffered AE */
|
||
|
||
/* Intermediate: the drop was real — node 1 received nothing. */
|
||
TEST_ASSERT_EQUAL_INT(0, c.nodes[1].log_count);
|
||
|
||
/* Recover the dropped entry via a fresh round. */
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
|
||
|
||
/* DUPLICATE: rewind the leader's belief so it re-sends the ALREADY-PRESENT
|
||
* entry 1 (next_index[1] = 1 ⇒ prev_log_index = 0, entry index 1). Buffer it
|
||
* twice and deliver both; both deliveries hit the already-have branch (the
|
||
* incoming entry matches node 1's local entry 1), so the log must NOT grow. */
|
||
c.nodes[0].state.next_index[1] = 1;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
|
||
TEST_ASSERT_TRUE(c.ae_pending_count >= 1);
|
||
/* Manually duplicate the just-buffered AE (re-sends entry 1). */
|
||
if (c.ae_pending_count < RAFT_SIM_MAX_AE) {
|
||
c.ae_pending[c.ae_pending_count] = c.ae_pending[c.ae_pending_count - 1];
|
||
c.ae_pending_count++;
|
||
}
|
||
raft_sim_deliver_append_entries(&c);
|
||
/* Idempotent: the duplicated already-have AE did not grow the log. */
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
|
||
|
||
/* REORDER (genuine out-of-order delivery): node 1 currently holds only
|
||
* entry 1. Buffer the AE for entry 2 (prev_log_index=1) and the AE for
|
||
* entry 3 (prev_log_index=2), then SWAP them so entry 3 is delivered FIRST.
|
||
* The entry-3 AE references a prev (index 2) node 1 does not yet have, so
|
||
* logOk fails (prev_log_index > log_len) ⇒ it is rejected without creating a
|
||
* gap; the entry-2 AE then appends normally. This proves reordered AEs cannot
|
||
* insert out-of-order (Req 8.3 reorder). */
|
||
c.ae_pending_count = 0; /* start the reorder round with a clean queue */
|
||
c.nodes[0].state.next_index[1] = 2; /* prev=1 → entry 2 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_send_append_entries(&c.nodes[0].state, 1, 0));
|
||
c.nodes[0].state.next_index[1] = 3; /* prev=2 → entry 3 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_send_append_entries(&c.nodes[0].state, 1, 0));
|
||
TEST_ASSERT_EQUAL_INT(2, c.ae_pending_count);
|
||
/* Swap so the entry-3 AE is delivered before the entry-2 AE. */
|
||
{
|
||
raft_sim_ae_t tmp = c.ae_pending[0];
|
||
c.ae_pending[0] = c.ae_pending[1];
|
||
c.ae_pending[1] = tmp;
|
||
}
|
||
raft_sim_deliver_append_entries(&c);
|
||
/* The out-of-order entry-3 AE was rejected (no gap); only entry 2 appended. */
|
||
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
|
||
|
||
/* Now deliver remaining rounds (entry 3) under continued churn; bounded loop
|
||
* drives to convergence regardless of transient reject/back-off. */
|
||
int rounds = 0;
|
||
while (c.nodes[1].log_count < c.nodes[0].log_count && rounds < 64) {
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
rounds++;
|
||
}
|
||
|
||
/* Converged despite drop + dup + back-off; Log Matching holds. */
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
|
||
for (int i = 0; i < 3; i++)
|
||
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[1].log[i].term);
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/*
|
||
* test_sim_9_4_chaotic_churn_g5_regression (task 9.4; Req 8.6 — bug-851)
|
||
*
|
||
* A stale-term node (term lagging) rejoins under a higher-term leader; assert it
|
||
* adopts the higher term from the AE/heartbeat path (UpdateTerm on AppendEntries,
|
||
* G5, task 5/6) and does NOT re-elect at a reused term. Log Matching holds across
|
||
* the churn. THIS is the bug-851 regression: silent committed-prefix divergence
|
||
* from two leaders at a reused term. It relies on UpdateTerm-on-AE and the
|
||
* SEND-time leader term in delivery (task 8.2).
|
||
*
|
||
* Load-bearing intermediate assertions:
|
||
* - pre-rejoin, the stale node's term is STRICTLY LESS than the leader's
|
||
* (so a no-op heartbeat would leave them divergent and the assertion real).
|
||
* - after the FIRST heartbeat, the rejoining node's term EQUALS the leader's
|
||
* (term adopted from the AE path) AND its role is FOLLOWER (stepped down).
|
||
* - no SECOND leader appears at the reused term (leader_count unchanged after
|
||
* the stale node is denied a re-election at its own old term).
|
||
*/
|
||
static void test_sim_9_4_chaotic_churn_g5_regression(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Elect node 0 leader and advance its term to model accumulated leadership
|
||
* churn — node 0 now leads at a high term while a partitioned node 2 lags. */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
c.nodes[0].state.term = 5;
|
||
uint64_t leader_term = c.nodes[0].state.term;
|
||
TEST_ASSERT_TRUE(leader_term >= 3);
|
||
|
||
/* Leader has a couple of entries. */
|
||
for (int e = 0; e < 2; e++) {
|
||
unsigned char payload[2] = { (unsigned char)('K' + e), 0x00 };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
}
|
||
|
||
/* Node 2 is a STALE-TERM rejoiner: force its term low (1) and role follower
|
||
* (as if it was partitioned away during the churn and never advanced). */
|
||
c.nodes[2].state.term = 1;
|
||
c.nodes[2].state.role = RAFT_FOLLOWER;
|
||
c.nodes[2].state.voted_for = -1;
|
||
|
||
/* Intermediate: stale node's term is strictly below the leader's. */
|
||
TEST_ASSERT_TRUE(c.nodes[2].state.term < leader_term);
|
||
|
||
int leader_count_before = c.leader_count;
|
||
|
||
/* Send a HEARTBEAT (n_entries handled by send AE: next_index[2] = Len(log)+1
|
||
* so prev_log_index points past node 2's empty log; this is the AE/heartbeat
|
||
* path). UpdateTerm must fire on this AE: node 2 adopts leader_term. */
|
||
sim_replicate_one(&c, 0, 2, 0);
|
||
|
||
/* G5: node 2 adopted the higher term from the AppendEntries/heartbeat path
|
||
* and stepped down to follower — NOT only from a vote RPC. */
|
||
TEST_ASSERT_EQUAL_UINT64(leader_term, c.nodes[2].state.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
|
||
|
||
/* UpdateTerm cleared the stale node's vote state: voted_for reset to -1 so it
|
||
* cannot double-vote in the new term, and any vote it casts now belongs to
|
||
* leader_term (>= the term it would have reused). This is the concrete state
|
||
* that prevents the reused-term double-leader of bug-851. */
|
||
TEST_ASSERT_EQUAL_INT(-1, c.nodes[2].state.voted_for);
|
||
|
||
/* No second leader was created during the rejoin/heartbeat delivery. */
|
||
TEST_ASSERT_EQUAL_INT(leader_count_before, c.leader_count);
|
||
|
||
/* The original leader is still the sole leader. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Drive catch-up so node 2 converges; Log Matching holds across the churn. */
|
||
int rounds = 0;
|
||
while (c.nodes[2].log_count < c.nodes[0].log_count && rounds < 64) {
|
||
sim_replicate_one(&c, 0, 2, 0);
|
||
rounds++;
|
||
}
|
||
TEST_ASSERT_EQUAL_INT(c.nodes[0].log_count, c.nodes[2].log_count);
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
|
||
/* Active check (after convergence, so it cannot disturb catch-up): if the
|
||
* rejoined node now starts an election it campaigns at a term STRICTLY ABOVE
|
||
* leader_term — never the stale term it lagged at — so it can never resurrect
|
||
* a committed-prefix divergence by reusing an old term (bug-851). */
|
||
raft_start_election(&c.nodes[2].state);
|
||
TEST_ASSERT_TRUE(c.nodes[2].state.term > leader_term);
|
||
}
|
||
|
||
/*
|
||
* test_sim_9_5_node_restart_resumes_replication (task 9.x; Req 8.3 — node restart
|
||
* while replication proceeds)
|
||
*
|
||
* A follower restarts MID-replication: its volatile consensus state is lost but
|
||
* its durable log (the in-memory log[], modelling the WAL) and persisted
|
||
* (term, voted_for) survive. Replication then resumes and converges with Log
|
||
* Matching intact. This closes the "node restart" sub-clause of Req 8.3 that the
|
||
* drop/dup/reorder test (9.3) did not chain.
|
||
*
|
||
* Load-bearing assertions:
|
||
* - immediately after restart the follower STILL holds its partial log
|
||
* (log_count preserved, NOT wiped to 0) and is back to FOLLOWER at the same
|
||
* durable term — proving the restart cleared volatile state while keeping the
|
||
* durable log;
|
||
* - replication resumes from the partial prefix and the follower converges to
|
||
* the leader's full log with Log Matching holding.
|
||
*/
|
||
static void test_sim_9_5_node_restart_resumes_replication(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Elect node 0 leader. */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Leader appends 3 entries. */
|
||
for (int e = 0; e < 3; e++) {
|
||
unsigned char payload[2] = { (unsigned char)('p' + e), 0x00 };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
}
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
|
||
|
||
/* Replicate PARTIALLY to node 1 (entries 1 and 2), leaving entry 3 pending. */
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
|
||
uint64_t durable_term = c.nodes[1].state.term;
|
||
|
||
/* RESTART node 1 mid-replication. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_restart_node(&c, 1));
|
||
|
||
/* Durable log survived the restart (NOT wiped); volatile state reset to a
|
||
* fresh FOLLOWER at the restored term and log length. */
|
||
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
|
||
TEST_ASSERT_EQUAL_UINT64(durable_term, c.nodes[1].state.term);
|
||
TEST_ASSERT_EQUAL_UINT64(2, c.nodes[1].state.log_len); /* restored from log_count */
|
||
|
||
/* Resume replication; bounded loop drives the rebooted follower to converge. */
|
||
int rounds = 0;
|
||
while (c.nodes[1].log_count < c.nodes[0].log_count && rounds < 64) {
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
rounds++;
|
||
}
|
||
|
||
/* Converged across the restart; Log Matching holds. */
|
||
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
|
||
for (int i = 0; i < 3; i++)
|
||
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[1].log[i].term);
|
||
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
|
||
}
|
||
|
||
/* ---------- Group: Simulator commit-safety invariant tests (tasks 7.1-7.4) ----------
|
||
*
|
||
* These observation-based invariant tests drive the COMMIT-SAFETY machinery
|
||
* through the simulator: raft_advance_commit (via raft_sim_advance_commit, the
|
||
* Figure-8 current-term rule), the on_commit_advanced trigger mirror (per-node
|
||
* commit_index / commit_advanced_count), Leader Completeness across forced
|
||
* leader changes, and the (lastLogTerm, lastLogIndex) lexicographic freshness
|
||
* gate on both the binding-vote and PreVote paths.
|
||
*
|
||
* Each test carries a LOAD-BEARING intermediate assertion that would fail if the
|
||
* corresponding behavior regressed (a Figure-8 hold that did not actually hold,
|
||
* a strict advance that did not fire on_commit_advanced exactly once, a freshness
|
||
* outcome that flipped, or a future leader missing a committed entry), so a
|
||
* trivially-passing run is impossible.
|
||
*/
|
||
|
||
/* Stamp a leader's sim log entry directly: set log[index-1].term and grow
|
||
* log_count / state.log_len so on_get_entry_term (sim_get_entry_term, which reads
|
||
* node->log) and the agreeIndexes scan (bounded by state.log_len) both see it.
|
||
* Used to construct a controlled Figure-8 prefix without driving replication. */
|
||
static void sim_set_log_entry(raft_sim_node_t *node, uint64_t index, uint64_t term)
|
||
{
|
||
TEST_ASSERT_TRUE(index >= 1 && index <= RAFT_SIM_MAX_LOG);
|
||
raft_sim_entry_t *slot = &node->log[index - 1];
|
||
slot->term = term;
|
||
slot->bytes[0] = (unsigned char)('0' + (index % 10));
|
||
slot->len = 1;
|
||
if ((int)index > node->log_count)
|
||
node->log_count = (int)index;
|
||
if (index > node->state.log_len)
|
||
node->state.log_len = index;
|
||
}
|
||
|
||
/*
|
||
* test_sim_7_1_figure8_commit_refusal (task 7.1; Req 6.2, 6.3)
|
||
*
|
||
* Figure-8 commit refusal (G4) driven through the simulator's
|
||
* raft_sim_advance_commit. A PREVIOUS-term (T1) entry at index 2 is replicated to
|
||
* a genuine quorum, but NO current-term (T3) entry above it has quorum yet:
|
||
* raft_advance_commit must HOLD commit_index at 0 (the current-term rule refuses
|
||
* to commit a prior-term entry by replica-count alone). THEN a CURRENT-term entry
|
||
* at index 3 reaches quorum: raft_advance_commit must advance commit_index to 3,
|
||
* indirectly committing the prior-term entry at index 2 as well.
|
||
*
|
||
* Load-bearing assertions:
|
||
* - the held phase has a REAL quorum on the prior-term entry (match_index[1]=2
|
||
* in a 3-node cluster ⇒ {self, peer1} agree through 2), so ONLY the
|
||
* current-term gate can be holding commit_index at 0. If the gate regressed
|
||
* to term-blind commit, the held assertion (commit_index == 0) would fail.
|
||
* - the released phase asserts commit_index advances over BOTH indices (3),
|
||
* and on_commit_advanced fired exactly once on that strict advance.
|
||
*/
|
||
static void test_sim_7_1_figure8_commit_refusal(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
raft_sim_node_t *L = &c.nodes[0];
|
||
|
||
/* Model a leader at term 3 carrying a committed-prefix shape from a churned
|
||
* past: entries 1,2 are PREVIOUS-term (T1) entries, replicated to a quorum,
|
||
* but the leader's current term is 3 with no T3 entry above them yet. */
|
||
L->state.role = RAFT_LEADER;
|
||
L->state.term = 3;
|
||
sim_set_log_entry(L, 1, 1); /* prior-term entry */
|
||
sim_set_log_entry(L, 2, 1); /* prior-term entry — the Figure-8 entry */
|
||
TEST_ASSERT_EQUAL_UINT64(2, L->state.log_len);
|
||
|
||
/* Genuine quorum on the prior-term entry: in a 3-node cluster {self} U {peer1}
|
||
* agree through index 2 (peer 1 match_index = 2). Max(agreeIndexes) = 2, but
|
||
* log[2].term (1) != currentTerm (3) ⇒ the current-term rule must HOLD. */
|
||
L->state.match_index[1] = 2;
|
||
L->state.match_index[2] = 0;
|
||
|
||
raft_sim_advance_commit(&c, 0);
|
||
|
||
/* HELD: a real quorum exists on index 2, so only the current-term gate can be
|
||
* keeping commit_index at 0. on_commit_advanced must NOT have fired. */
|
||
TEST_ASSERT_EQUAL_UINT64(0, L->state.commit_index);
|
||
TEST_ASSERT_EQUAL_UINT64(0, L->commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, L->commit_advanced_count);
|
||
|
||
/* Now a CURRENT-term (T3) entry lands at index 3 and reaches quorum. */
|
||
sim_set_log_entry(L, 3, 3);
|
||
TEST_ASSERT_EQUAL_UINT64(3, L->state.log_len);
|
||
L->state.match_index[1] = 3; /* {self, peer1} now agree through 3 */
|
||
|
||
raft_sim_advance_commit(&c, 0);
|
||
|
||
/* RELEASED: committing the current-term entry at 3 indirectly commits the
|
||
* prior-term entries below it — commit_index advances over BOTH (to 3) and
|
||
* on_commit_advanced fired exactly once on the strict advance. */
|
||
TEST_ASSERT_EQUAL_UINT64(3, L->state.commit_index);
|
||
TEST_ASSERT_EQUAL_UINT64(3, L->commit_index);
|
||
TEST_ASSERT_EQUAL_INT(1, L->commit_advanced_count);
|
||
}
|
||
|
||
/*
|
||
* test_sim_7_2_leader_completeness_across_changes (task 7.2; Req 6.4)
|
||
*
|
||
* Leader Completeness across forced leader changes: an entry committed under one
|
||
* leader must be present in the log of EVERY node that subsequently becomes
|
||
* leader. Node 0 leads, replicates entries to a quorum (nodes 0 and 1) and
|
||
* commits them. Node 0 then "fails" (partitioned away). The freshness gate plus
|
||
* a real election force a NEW leader — and because only nodes holding the
|
||
* committed prefix can win a quorum, the new leader necessarily holds it.
|
||
*
|
||
* Load-bearing assertions:
|
||
* - node 2 (which never received the committed entries) is DENIED the vote by
|
||
* the freshness gate (its last-log tuple is staler than the up-to-date
|
||
* voters), so it cannot become a leader missing the committed prefix — if the
|
||
* freshness gate regressed, node 2 could win and the completeness assertion
|
||
* would be violated.
|
||
* - the node that DOES become the new leader holds every committed entry
|
||
* (term-for-term equal to the original committing leader's prefix).
|
||
*/
|
||
static void test_sim_7_2_leader_completeness_across_changes(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Elect node 0 leader at term 1. */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Replicate 2 entries to the quorum {node0, node1}; node 2 is isolated so it
|
||
* NEVER receives them (it will become the stale, completeness-violating
|
||
* candidate). */
|
||
raft_sim_partition(&c, 2, 1);
|
||
for (int e = 0; e < 2; e++) {
|
||
unsigned char payload[2] = { (unsigned char)('C' + e), 0x00 };
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
|
||
sim_replicate_one(&c, 0, 1, 0);
|
||
}
|
||
/* Commit the current-term entries (quorum {self, peer1} through index 2). */
|
||
raft_sim_advance_commit(&c, 0);
|
||
uint64_t committed = c.nodes[0].state.commit_index;
|
||
TEST_ASSERT_EQUAL_UINT64(2, committed);
|
||
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count); /* the committed prefix is on node 1 */
|
||
|
||
/* Snapshot the committing leader's committed prefix for the completeness check. */
|
||
raft_sim_entry_t committed_prefix[2];
|
||
for (uint64_t i = 1; i <= committed; i++)
|
||
committed_prefix[i - 1] = c.nodes[0].log[i - 1];
|
||
|
||
/* FORCE A LEADER CHANGE: node 0 dies (partitioned away). Heal node 2 so it is
|
||
* back on the network — it is the up-to-date node 1's only peer now. */
|
||
raft_sim_partition(&c, 0, 1);
|
||
raft_sim_partition(&c, 2, 0);
|
||
|
||
/* The stale node 2 (empty log) campaigns first. Its RequestVote advertises an
|
||
* empty last-log tuple; the up-to-date node 1 (last-log term 1, index 2) must
|
||
* DENY it on the (lastLogTerm, lastLogIndex) freshness gate. */
|
||
raft_start_election(&c.nodes[2].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_NOT_EQUAL(RAFT_LEADER, c.nodes[2].state.role);
|
||
/* Load-bearing: node 1 refused node 2 (no quorum for the stale candidate). */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
|
||
|
||
/* Now the up-to-date node 1 campaigns. Node 2 (no committed prefix, staler
|
||
* tuple) GRANTS the fresher candidate, so node 1 — which holds the committed
|
||
* prefix — wins and becomes the new leader. */
|
||
raft_start_election(&c.nodes[1].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[1].state.role);
|
||
|
||
/* LEADER COMPLETENESS: the node that became leader holds EVERY committed
|
||
* entry, term-for-term, from the original committing leader. */
|
||
TEST_ASSERT_TRUE(c.nodes[1].log_count >= (int)committed);
|
||
for (uint64_t i = 1; i <= committed; i++) {
|
||
TEST_ASSERT_EQUAL_UINT64(committed_prefix[i - 1].term,
|
||
c.nodes[1].log[i - 1].term);
|
||
TEST_ASSERT_EQUAL_size_t(committed_prefix[i - 1].len,
|
||
c.nodes[1].log[i - 1].len);
|
||
TEST_ASSERT_EQUAL_MEMORY(committed_prefix[i - 1].bytes,
|
||
c.nodes[1].log[i - 1].bytes,
|
||
committed_prefix[i - 1].len);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* test_sim_7_3_freshness_gate_lexicographic (task 7.3; Req 6.5)
|
||
*
|
||
* Exercise the (last_log_term, last_log_index) lexicographic freshness gate on
|
||
* BOTH the binding-vote path (raft_on_vote_request) and the side-effect-free
|
||
* PreVote path (raft_on_pre_vote_request) with mismatched tuples:
|
||
* - stale-term-but-HIGHER-index → DENIED (term dominates index)
|
||
* - fresher-term-but-LOWER-index → GRANTED (term dominates index)
|
||
* - tie-term decided by index → GRANTED iff cand index >= voter index
|
||
* The voter holds (last_log_term 5, last_log_index 4).
|
||
*
|
||
* Load-bearing: the stale-term-higher-index DENY and the fresher-term-lower-index
|
||
* GRANT are the exact pair that a pure LSN comparison (the pre-spec G6 bug) would
|
||
* get BACKWARDS — so these assertions fail if the gate regressed to index-only.
|
||
*/
|
||
static void test_sim_7_3_freshness_gate_lexicographic(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Voter is node 1 at term 5 with last-log (term 5, index 4). The sim's
|
||
* on_get_entry_term reads node->log, so stamp index 4 with term 5. */
|
||
raft_sim_node_t *V = &c.nodes[1];
|
||
V->state.term = 5;
|
||
V->state.role = RAFT_FOLLOWER;
|
||
V->state.voted_for = -1;
|
||
sim_set_log_entry(V, 1, 5);
|
||
sim_set_log_entry(V, 2, 5);
|
||
sim_set_log_entry(V, 3, 5);
|
||
sim_set_log_entry(V, 4, 5);
|
||
TEST_ASSERT_EQUAL_UINT64(4, V->state.log_len);
|
||
|
||
uint64_t reply_term = 0;
|
||
|
||
/* --- Binding-vote path (raft_on_vote_request) --- */
|
||
|
||
/* Stale last-log TERM (4) but HIGHER index (10): term dominates ⇒ DENY. */
|
||
int rc = raft_on_vote_request(&V->state, 0, 5, /*cand_lt=*/4, /*cand_li=*/10,
|
||
&reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(-1, V->state.voted_for); /* not granted */
|
||
|
||
/* Fresher last-log TERM (6) but LOWER index (2): term dominates ⇒ GRANT. */
|
||
rc = raft_on_vote_request(&V->state, 0, 6, /*cand_lt=*/6, /*cand_li=*/2,
|
||
&reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
/* Re-init the voter for the tie-term cases (fresh, unvoted, same log). */
|
||
V->state.term = 5;
|
||
V->state.role = RAFT_FOLLOWER;
|
||
V->state.voted_for = -1;
|
||
V->state.voted_for_term = 0;
|
||
|
||
/* Tie TERM (5), index EQUAL to voter's (4): index >= voter ⇒ GRANT. */
|
||
rc = raft_on_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/4,
|
||
&reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
V->state.term = 5;
|
||
V->state.role = RAFT_FOLLOWER;
|
||
V->state.voted_for = -1;
|
||
V->state.voted_for_term = 0;
|
||
|
||
/* Tie TERM (5), index LOWER than voter's (3 < 4) ⇒ DENY. */
|
||
rc = raft_on_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/3,
|
||
&reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
|
||
/* --- PreVote path (raft_on_pre_vote_request) — same lexicographic predicate,
|
||
* side-effect free. Reset the voter to a clean (term 5, last-log 5@4). --- */
|
||
V->state.term = 5;
|
||
V->state.role = RAFT_FOLLOWER;
|
||
V->state.voted_for = -1;
|
||
V->state.voted_for_term = 0;
|
||
|
||
/* Stale term (4), higher index (10) ⇒ DENY (and no mutation). */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY,
|
||
raft_on_pre_vote_request(&V->state, 0, 5, /*cand_lt=*/4, /*cand_li=*/10));
|
||
/* Fresher term (6), lower index (2) ⇒ GRANT. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT,
|
||
raft_on_pre_vote_request(&V->state, 0, 6, /*cand_lt=*/6, /*cand_li=*/2));
|
||
/* Tie term (5), equal index (4) ⇒ GRANT; tie term, lower index (3) ⇒ DENY. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT,
|
||
raft_on_pre_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/4));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY,
|
||
raft_on_pre_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/3));
|
||
|
||
/* PreVote was side-effect-free across all four probes. */
|
||
TEST_ASSERT_EQUAL_UINT64(5, V->state.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, V->state.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, V->state.role);
|
||
}
|
||
|
||
/*
|
||
* test_sim_7_4_on_commit_advanced_trigger_semantics (task 7.4; Req 6.6, 2.2, 2.3)
|
||
*
|
||
* Trigger semantics for on_commit_advanced via the per-node commit_advanced_count
|
||
* mirror. The callback must fire EXACTLY ONCE on a strict advance and must NOT
|
||
* fire while the Figure-8 current-term rule HOLDS the commit index, nor when the
|
||
* node is not the leader. The fire-count must equal the number of strict advances.
|
||
*
|
||
* Phases (one leader node, deterministic match_index/term control):
|
||
* 1. HELD: prior-term entries with quorum, no current-term entry above ⇒ NO fire.
|
||
* 2. ADVANCE #1: a current-term entry reaches quorum ⇒ EXACTLY ONE fire.
|
||
* 3. NO-OP RE-DRIVE: same state re-driven ⇒ no additional fire (not a strict advance).
|
||
* 4. ADVANCE #2: another current-term entry reaches quorum ⇒ ONE more fire (count 2).
|
||
* 5. NOT LEADER: as a follower, raft_advance_commit is -EPERM ⇒ no fire.
|
||
*
|
||
* Load-bearing: the held phase (real quorum, no fire) is distinguished from the
|
||
* advance phase (one fire), and the no-op re-drive proves the count tracks STRICT
|
||
* advances — not raft_advance_commit invocations. If the held gate or the
|
||
* strict-advance firing regressed, the per-phase count assertions fail.
|
||
*/
|
||
static void test_sim_7_4_on_commit_advanced_trigger_semantics(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
raft_sim_node_t *L = &c.nodes[0];
|
||
L->state.role = RAFT_LEADER;
|
||
L->state.term = 4;
|
||
|
||
/* Phase 1 — HELD: two prior-term (T2) entries replicated to a quorum, no
|
||
* current-term entry above them. */
|
||
sim_set_log_entry(L, 1, 2);
|
||
sim_set_log_entry(L, 2, 2);
|
||
L->state.match_index[1] = 2; /* genuine quorum {self, peer1} through 2 */
|
||
L->state.match_index[2] = 0;
|
||
|
||
raft_sim_advance_commit(&c, 0);
|
||
TEST_ASSERT_EQUAL_UINT64(0, L->state.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, L->commit_advanced_count); /* held — no fire */
|
||
|
||
/* Phase 2 — ADVANCE #1: a current-term (T4) entry at index 3 reaches quorum. */
|
||
sim_set_log_entry(L, 3, 4);
|
||
L->state.match_index[1] = 3;
|
||
raft_sim_advance_commit(&c, 0);
|
||
TEST_ASSERT_EQUAL_UINT64(3, L->state.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(1, L->commit_advanced_count); /* exactly one fire */
|
||
|
||
/* Phase 3 — NO-OP RE-DRIVE: nothing changed; Max(agreeIndexes) is unchanged so
|
||
* the commit index does not strictly advance ⇒ NO additional fire. */
|
||
raft_sim_advance_commit(&c, 0);
|
||
TEST_ASSERT_EQUAL_UINT64(3, L->state.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(1, L->commit_advanced_count); /* still one — count tracks advances */
|
||
|
||
/* Phase 4 — ADVANCE #2: another current-term (T4) entry at index 4 reaches
|
||
* quorum ⇒ ONE more strict advance (count 2). */
|
||
sim_set_log_entry(L, 4, 4);
|
||
L->state.match_index[1] = 4;
|
||
raft_sim_advance_commit(&c, 0);
|
||
TEST_ASSERT_EQUAL_UINT64(4, L->state.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(2, L->commit_advanced_count); /* fire count == strict advances */
|
||
|
||
/* Phase 5 — NOT LEADER: a follower's raft_advance_commit is -EPERM; even with a
|
||
* quorum that would otherwise commit a current-term entry at 5, NO fire occurs
|
||
* and commit_index is untouched. */
|
||
sim_set_log_entry(L, 5, 4);
|
||
L->state.match_index[1] = 5;
|
||
L->state.role = RAFT_FOLLOWER;
|
||
raft_sim_advance_commit(&c, 0);
|
||
TEST_ASSERT_EQUAL_UINT64(4, L->state.commit_index); /* unchanged off-leader */
|
||
TEST_ASSERT_EQUAL_INT(2, L->commit_advanced_count); /* no fire off-leader */
|
||
}
|
||
|
||
/*
|
||
* test_sim_conformance_init_append_readback (raft-sim-conformance task 1.2; Req 1.1, 1.2, 2.1)
|
||
*
|
||
* Observable completion for the conformance init + in-memory adapter callbacks:
|
||
* - raft_sim_conformance_init(&c, n, timeout, seed) wires the six conformance
|
||
* adapter callbacks (log persist/get-term/truncate + conformance commit +
|
||
* send-to-bag) and seeds the deterministic PRNG from `seed`.
|
||
* - Drive node 0 to LEADER through the published election entry points
|
||
* (start_election on node 0, deliver the pending vote requests so peers grant).
|
||
* - raft_log_append a known value on the leader; the entry must be readable back
|
||
* two ways: directly via node0.log[0] (term + decoded value) AND through the
|
||
* wired on_get_entry_term callback (returns the entry's term, not 0).
|
||
* - Determinism smoke: two conformance inits with the SAME seed leave c->prng in
|
||
* the same state (R1.2: every nondeterministic choice flows from the seed).
|
||
*/
|
||
static void test_sim_conformance_init_append_readback(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
/* Single-node conformance cluster: raft_start_election short-circuits node 0
|
||
* straight to LEADER (no peers to canvass), so the leader is forced without
|
||
* depending on bag delivery (task 2.1). The PRNG seed is still asserted. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c, 1, 2000, 0xC0FFEEULL));
|
||
|
||
/* PRNG seeded from the run key. */
|
||
TEST_ASSERT_EQUAL_UINT64(0xC0FFEEULL, c.seed);
|
||
|
||
/* Node 0 starts as a follower, no leader yet. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[0].state.role);
|
||
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
|
||
|
||
/* Force node 0 to LEADER via the published election entry point. */
|
||
raft_start_election(&c.nodes[0].state);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
|
||
/* Append a known client value on the leader. The first 8 bytes decode to the
|
||
* applied value; the conformance adapter stores it in node0.log[0]. */
|
||
uint64_t value = 0x1122334455667788ULL;
|
||
uint64_t out_index = 0;
|
||
int rc = raft_log_append(&c.nodes[0].state, &value, sizeof(value), &out_index);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(1, out_index);
|
||
|
||
/* Read back directly via the in-memory log: index 1 -> log[0]. */
|
||
raft_sim_node_t *L = &c.nodes[0];
|
||
TEST_ASSERT_EQUAL_INT(1, L->log_count);
|
||
TEST_ASSERT_EQUAL_UINT64(L->state.term, L->log[0].term);
|
||
uint64_t decoded = 0;
|
||
memcpy(&decoded, L->log[0].bytes, sizeof(decoded));
|
||
TEST_ASSERT_EQUAL_UINT64(value, decoded);
|
||
|
||
/* Read back the entry's term through the wired on_get_entry_term callback. */
|
||
uint64_t got_term = L->cbs.on_get_entry_term(L, 1);
|
||
TEST_ASSERT_EQUAL_UINT64(L->state.term, got_term);
|
||
TEST_ASSERT_NOT_EQUAL(0, got_term);
|
||
|
||
/* Determinism smoke: same seed -> identical PRNG state after init. */
|
||
raft_sim_cluster_t c2;
|
||
memset(&c2, 0, sizeof(c2));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c2, 1, 2000, 0xC0FFEEULL));
|
||
TEST_ASSERT_EQUAL_UINT64(c.prng, c2.prng); /* same seed -> same start state */
|
||
|
||
/* The seeded generator advances identically for the same seed: the first few
|
||
* draws match, and the post-draw state stays in lockstep (R1.2 determinism). */
|
||
raft_sim_cluster_t g1, g2;
|
||
memset(&g1, 0, sizeof(g1));
|
||
memset(&g2, 0, sizeof(g2));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&g1, 1, 2000, 0x5EEDULL));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&g2, 1, 2000, 0x5EEDULL));
|
||
for (int k = 0; k < 4; k++)
|
||
TEST_ASSERT_EQUAL_UINT64(raft_sim_prng_next(&g1), raft_sim_prng_next(&g2));
|
||
TEST_ASSERT_EQUAL_UINT64(g1.prng, g2.prng);
|
||
}
|
||
|
||
/*
|
||
* test_sim_conformance_bag_deliver_routes_rpcs (raft-sim-conformance task 2.1; Req 2.2, 2.4)
|
||
*
|
||
* Observable completion for raft_sim_bag_push / raft_sim_bag_deliver(i):
|
||
* - A pushed VOTE_REQ delivered by index invokes raft_on_vote_request on dst and
|
||
* pushes a VOTE_RSP back onto the bag (src/dst swapped, granted=result). The
|
||
* request is consumed (swap-with-last removal); the reply is the surviving member.
|
||
* - A pushed AE_REQ delivered by index invokes raft_on_append_entries_request on
|
||
* dst (replicating the single entry) and pushes an AE_RSP back (src/dst swapped,
|
||
* success=result). The dst's in-memory log grows (handler ran).
|
||
* - A request to a PARTITIONED dst is NOT delivered: no reply is pushed, the dst
|
||
* state is unchanged, and the request stays in the bag (mirrors a dropped edge).
|
||
*/
|
||
static void test_sim_conformance_bag_deliver_routes_rpcs(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
/* 3-node conformance cluster; force node 0 to LEADER so it owns a real log
|
||
* tail and per-peer progress for a genuine AppendEntries. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c, 3, 2000, 0xBA61ULL));
|
||
|
||
raft_start_election(&c.nodes[0].state); /* sends vote reqs to the bag */
|
||
/* Drain the election vote traffic via direct delivery so node 0 wins, leaving
|
||
* the bag empty for the targeted assertions below. */
|
||
for (int g = 0; g < 64 && c.bag_count > 0; g++)
|
||
raft_sim_bag_deliver(&c, 0); /* always deliver head */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* ---- VOTE_REQ routing ---- */
|
||
c.bag_count = 0;
|
||
raft_sim_msg_t vreq;
|
||
memset(&vreq, 0, sizeof(vreq));
|
||
vreq.kind = RAFT_SIM_MSG_VOTE_REQ;
|
||
vreq.src = 1; /* candidate */
|
||
vreq.dst = 2; /* voter */
|
||
vreq.term = c.nodes[1].state.term + 5; /* fresh higher term -> grant */
|
||
vreq.last_log_term = 0; /* empty-log candidate vs empty voter */
|
||
vreq.last_log_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &vreq));
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_deliver(&c, 0));
|
||
/* Request consumed, exactly one VOTE_RSP now in the bag, src/dst swapped. */
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_VOTE_RSP, c.bag[0].kind);
|
||
TEST_ASSERT_EQUAL_INT(2, c.bag[0].src); /* voter replies */
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag[0].dst); /* back to candidate */
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag[0].granted);
|
||
/* Handler ran on dst: node 2 adopted the higher term and recorded the vote. */
|
||
TEST_ASSERT_EQUAL_UINT64(vreq.term, c.nodes[2].state.term);
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].state.voted_for);
|
||
|
||
/* ---- AE_REQ routing ---- */
|
||
c.bag_count = 0;
|
||
/* Leader appends one client value, then sends AppendEntries to peer 1 — the
|
||
* conformance send callback pushes a real AE_REQ onto the bag. */
|
||
uint64_t value = 0xABCDEF12u;
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, &value, sizeof(value), &out_index));
|
||
TEST_ASSERT_EQUAL_UINT64(1, out_index);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_REQ, c.bag[0].kind);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_deliver(&c, 0));
|
||
/* AE request consumed, one AE_RSP remains, src/dst swapped, success. */
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_RSP, c.bag[0].kind);
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag[0].src); /* follower replies */
|
||
TEST_ASSERT_EQUAL_INT(0, c.bag[0].dst); /* back to leader */
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag[0].success);
|
||
/* Handler ran on dst: node 1 replicated the single entry. */
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
|
||
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[0].term, c.nodes[1].log[0].term);
|
||
|
||
/* ---- partitioned dst is NOT delivered ---- */
|
||
c.bag_count = 0;
|
||
raft_sim_partition(&c, 2, 1); /* isolate the voter */
|
||
int term2_before = (int)c.nodes[2].state.term;
|
||
int votedfor2_before = c.nodes[2].state.voted_for;
|
||
|
||
raft_sim_msg_t vreq2;
|
||
memset(&vreq2, 0, sizeof(vreq2));
|
||
vreq2.kind = RAFT_SIM_MSG_VOTE_REQ;
|
||
vreq2.src = 1;
|
||
vreq2.dst = 2; /* partitioned */
|
||
vreq2.term = c.nodes[2].state.term + 9;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &vreq2));
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
|
||
|
||
/* Delivery to a partitioned endpoint is a no-op: distinct non-zero status,
|
||
* the request stays in the bag, no reply pushed, dst state unchanged. */
|
||
TEST_ASSERT_NOT_EQUAL(0, raft_sim_bag_deliver(&c, 0));
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count); /* request not consumed */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_VOTE_REQ, c.bag[0].kind); /* still the request */
|
||
TEST_ASSERT_EQUAL_INT(term2_before, (int)c.nodes[2].state.term);
|
||
TEST_ASSERT_EQUAL_INT(votedfor2_before, c.nodes[2].state.voted_for);
|
||
}
|
||
|
||
/* ----- task 2.2: Drop / Duplicate / Reorder mutate ONLY the bag ----- */
|
||
|
||
/* Observable per-node snapshot for the R2.5 "node state UNCHANGED" assertion.
|
||
* Captures exactly the non-message vars raft.tla:443/448 keep UNCHANGED across
|
||
* DuplicateMessage/DropMessage: serverVars (term, role, votedFor) + logVars
|
||
* (the log entries and length). memcmp-comparable (memset to zero first). */
|
||
typedef struct {
|
||
uint64_t term;
|
||
raft_role_t role;
|
||
int voted_for;
|
||
int log_count;
|
||
uint64_t commit_index;
|
||
raft_sim_entry_t log[RAFT_SIM_MAX_LOG];
|
||
} sim_node_state_snapshot_t;
|
||
|
||
static void sim_snapshot_node(const raft_sim_node_t *node,
|
||
sim_node_state_snapshot_t *out)
|
||
{
|
||
memset(out, 0, sizeof(*out));
|
||
out->term = node->state.term;
|
||
out->role = node->state.role;
|
||
out->voted_for = node->state.voted_for;
|
||
out->log_count = node->log_count;
|
||
out->commit_index = node->commit_index;
|
||
for (int i = 0; i < node->log_count && i < RAFT_SIM_MAX_LOG; i++)
|
||
out->log[i] = node->log[i];
|
||
}
|
||
|
||
/*
|
||
* test_sim_2_2_drop_duplicate_preserve_node_state (task 2.2; Req 2.5)
|
||
*
|
||
* R2.5 / raft.tla:443/448: DropMessage and DuplicateMessage change bag
|
||
* multiplicity ONLY — every node's (term, role, votedFor, log) is byte-identical
|
||
* before and after. The bag operations never touch any node:
|
||
* - raft_sim_bag_drop(c, i): bag_count -= 1, node snapshots identical.
|
||
* - raft_sim_bag_duplicate(c, j): bag_count += 1, the duplicate's content equals
|
||
* the original, node snapshots still identical.
|
||
* Bounds are fail-loud (return < 0) and leave the bag untouched.
|
||
*/
|
||
static void test_sim_2_2_drop_duplicate_preserve_node_state(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
/* 3-node conformance cluster; give nodes some real, distinct state so the
|
||
* "UNCHANGED" assertion has teeth (non-trivial term/role/votedFor/log). */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c, 3, 2000, 0xD0D0ULL));
|
||
raft_start_election(&c.nodes[0].state); /* node 0 -> candidate */
|
||
for (int g = 0; g < 64 && c.bag_count > 0; g++)
|
||
raft_sim_bag_deliver(&c, 0); /* node 0 wins -> LEADER */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
uint64_t value = 0x1122334455667788ULL;
|
||
uint64_t out_index = 0;
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK,
|
||
raft_log_append(&c.nodes[0].state, &value, sizeof(value), &out_index));
|
||
|
||
/* Seed the bag directly with two UNDELIVERED messages (never routed, so no
|
||
* node was touched by them). */
|
||
c.bag_count = 0;
|
||
raft_sim_msg_t m0;
|
||
memset(&m0, 0, sizeof(m0));
|
||
m0.kind = RAFT_SIM_MSG_VOTE_REQ;
|
||
m0.src = 1; m0.dst = 2; m0.term = 7;
|
||
raft_sim_msg_t m1;
|
||
memset(&m1, 0, sizeof(m1));
|
||
m1.kind = RAFT_SIM_MSG_AE_REQ;
|
||
m1.src = 0; m1.dst = 1; m1.term = 3; m1.entries_len = 1;
|
||
m1.entry_term = 3; m1.entry_value = 0xFEEDFACEULL;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &m0));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &m1));
|
||
TEST_ASSERT_EQUAL_INT(2, c.bag_count);
|
||
|
||
/* Snapshot every node BEFORE the bag mutations. */
|
||
sim_node_state_snapshot_t before[RAFT_SIM_MAX_NODES];
|
||
for (int i = 0; i < c.n; i++)
|
||
sim_snapshot_node(&c.nodes[i], &before[i]);
|
||
|
||
/* ---- DROP ---- discard the AE_REQ at index 1; multiplicity drops by 1. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_drop(&c, 1));
|
||
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
|
||
for (int i = 0; i < c.n; i++) {
|
||
sim_node_state_snapshot_t after;
|
||
sim_snapshot_node(&c.nodes[i], &after);
|
||
TEST_ASSERT_EQUAL_MEMORY(&before[i], &after, sizeof(after));
|
||
}
|
||
|
||
/* ---- DUPLICATE ---- re-enqueue a copy of the surviving message (index 0);
|
||
* multiplicity rises by 1, the duplicate content equals the original. */
|
||
raft_sim_msg_t orig = c.bag[0];
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_duplicate(&c, 0));
|
||
TEST_ASSERT_EQUAL_INT(2, c.bag_count);
|
||
TEST_ASSERT_EQUAL_MEMORY(&orig, &c.bag[0], sizeof(orig)); /* original intact */
|
||
TEST_ASSERT_EQUAL_MEMORY(&orig, &c.bag[1], sizeof(orig)); /* copy at tail */
|
||
for (int i = 0; i < c.n; i++) {
|
||
sim_node_state_snapshot_t after;
|
||
sim_snapshot_node(&c.nodes[i], &after);
|
||
TEST_ASSERT_EQUAL_MEMORY(&before[i], &after, sizeof(after));
|
||
}
|
||
|
||
/* ---- bounds: fail-loud, bag untouched ---- */
|
||
int saved_count = c.bag_count;
|
||
TEST_ASSERT_TRUE(raft_sim_bag_drop(&c, -1) < 0);
|
||
TEST_ASSERT_TRUE(raft_sim_bag_drop(&c, c.bag_count) < 0);
|
||
TEST_ASSERT_TRUE(raft_sim_bag_duplicate(&c, -1) < 0);
|
||
TEST_ASSERT_TRUE(raft_sim_bag_duplicate(&c, c.bag_count) < 0);
|
||
TEST_ASSERT_EQUAL_INT(saved_count, c.bag_count);
|
||
}
|
||
|
||
/*
|
||
* test_sim_2_2_reorder_prng_deterministic (task 2.2; Req 2.5)
|
||
*
|
||
* Reorder: raft_sim_bag_pick(c) returns a PRNG-chosen in-flight index so delivery
|
||
* order is PRNG-determined, not FIFO. Same seed => same pick sequence (replay), and
|
||
* picking is a pure read: it mutates no node's (term, role, votedFor, log).
|
||
*/
|
||
static void test_sim_2_2_reorder_prng_deterministic(void)
|
||
{
|
||
raft_sim_cluster_t a, b;
|
||
memset(&a, 0, sizeof(a));
|
||
memset(&b, 0, sizeof(b));
|
||
|
||
/* Two clusters, identical seed: pick sequences must match byte-for-byte. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&a, 3, 2000, 0xAB1EULL));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&b, 3, 2000, 0xAB1EULL));
|
||
|
||
/* Push >=2 distinct messages onto each (same content) so a pick can vary. */
|
||
for (int k = 0; k < 4; k++) {
|
||
raft_sim_msg_t m;
|
||
memset(&m, 0, sizeof(m));
|
||
m.kind = RAFT_SIM_MSG_VOTE_REQ;
|
||
m.src = 0; m.dst = 1; m.term = (uint64_t)(k + 1);
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&a, &m));
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&b, &m));
|
||
}
|
||
TEST_ASSERT_EQUAL_INT(4, a.bag_count);
|
||
|
||
/* Snapshot node state before any picks (picking must not mutate nodes). */
|
||
sim_node_state_snapshot_t before[RAFT_SIM_MAX_NODES];
|
||
for (int i = 0; i < a.n; i++)
|
||
sim_snapshot_node(&a.nodes[i], &before[i]);
|
||
|
||
for (int step = 0; step < 16; step++) {
|
||
int pa = raft_sim_bag_pick(&a);
|
||
int pb = raft_sim_bag_pick(&b);
|
||
TEST_ASSERT_EQUAL_INT(pa, pb); /* deterministic replay */
|
||
TEST_ASSERT_TRUE(pa >= 0 && pa < a.bag_count); /* names a live member */
|
||
}
|
||
|
||
for (int i = 0; i < a.n; i++) {
|
||
sim_node_state_snapshot_t after;
|
||
sim_snapshot_node(&a.nodes[i], &after);
|
||
TEST_ASSERT_EQUAL_MEMORY(&before[i], &after, sizeof(after));
|
||
}
|
||
|
||
/* Empty bag: pick returns < 0 (nothing to deliver). */
|
||
a.bag_count = 0;
|
||
TEST_ASSERT_TRUE(raft_sim_bag_pick(&a) < 0);
|
||
}
|
||
|
||
/* ---------- Group A: Basic Election Correctness (task 5.1) ---------- */
|
||
|
||
/*
|
||
* test_proto_A1_single_node_self_elects
|
||
*
|
||
* 1-node cluster: raft_start_election returns RAFT_ELECTED_IMMEDIATELY.
|
||
* role==LEADER, leader_count==1, no on_send_vote_request calls (no pending RPCs).
|
||
* on_become_leader fired once.
|
||
*/
|
||
static void test_proto_A1_single_node_self_elects(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 1, 2000));
|
||
|
||
int rc = raft_start_election(&c.nodes[0].state);
|
||
|
||
/* Single-node shortcircuit */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* on_become_leader fired once → leader_count incremented */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
|
||
/* No pending RPCs — no on_send_vote_request calls */
|
||
TEST_ASSERT_EQUAL_INT(0, c.pending_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_A2_3node_one_leader
|
||
*
|
||
* 3-node cluster: node 0 (highest priority, idx=0) wins the election.
|
||
* After deliver_pending: leader_count==1, node 0 is LEADER, others FOLLOWER.
|
||
*/
|
||
static void test_proto_A2_3node_one_leader(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Only node 0 starts — it will ask nodes 1 and 2 for votes */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Exactly one leader */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
|
||
}
|
||
|
||
/*
|
||
* test_proto_A3_late_election_denied
|
||
*
|
||
* Node 0 wins at term=1. Node 1 already voted_for=0 at term=1.
|
||
* Send a vote request from idx=2 to node 1 at term=1 directly.
|
||
* Node 1 must DENY (already voted this term), leader_count stays 1.
|
||
*/
|
||
static void test_proto_A3_late_election_denied(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Node 0 wins at term=1 */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
|
||
/* Node 1 granted node 0 at term=1: voted_for=0, voted_for_term=1 */
|
||
TEST_ASSERT_EQUAL_INT(0, c.nodes[1].state.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(1, c.nodes[1].state.voted_for_term);
|
||
|
||
/* Now node 2 tries to request a vote from node 1 at the same term=1 */
|
||
uint64_t reply_term = 0;
|
||
int rc = raft_on_vote_request(&c.nodes[1].state, 2, 1, 0, 0, &reply_term);
|
||
|
||
/* Node 1 must deny — already voted for node 0 this term */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
|
||
/* leader_count still 1 (no new leader elected) */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_A4_5node_quorum_3
|
||
*
|
||
* 5-node cluster: node 0 starts the election.
|
||
* Quorum=3 (self + 2 peer grants). After deliver, node 0 is LEADER.
|
||
*/
|
||
static void test_proto_A4_5node_quorum_3(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 5, 2000));
|
||
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
/* Self vote + 4 peer grants → election_decided=1 after first quorum reached */
|
||
TEST_ASSERT_GREATER_OR_EQUAL(3, c.nodes[0].state.votes_granted);
|
||
}
|
||
|
||
/*
|
||
* test_proto_A5_leader_denies_vote_request
|
||
*
|
||
* Node 0 is LEADER at term=1. Receives a vote request from idx=1 at term=1.
|
||
* The role gate must deny (LEADER, same term → no step-down).
|
||
* Node 0 stays LEADER.
|
||
*/
|
||
static void test_proto_A5_leader_denies_vote_request(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Make node 0 the leader */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
uint64_t term_before = c.nodes[0].state.term; /* =1 */
|
||
int lc_before = c.leader_count;
|
||
|
||
/* Send a vote request to the leader at the same term */
|
||
uint64_t reply_term = 0;
|
||
int rc = raft_on_vote_request(&c.nodes[0].state, 1, term_before, 0, 0, &reply_term);
|
||
|
||
/* Must be denied */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
|
||
/* Node 0 remains LEADER */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* No new leader elected */
|
||
TEST_ASSERT_EQUAL_INT(lc_before, c.leader_count);
|
||
}
|
||
|
||
/* ---------- Group C: on_vote_eligible policy callback (task 5.2) ---------- */
|
||
|
||
/*
|
||
* test_proto_C1_eligible_denies
|
||
*
|
||
* on_vote_eligible returns 0 → RAFT_DENY, voted_for unchanged (-1),
|
||
* vote_eligible_call_count == 1.
|
||
*/
|
||
static void test_proto_C1_eligible_denies(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 0; /* deny */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 1;
|
||
s.voted_for = -1;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_C2_eligible_grants
|
||
*
|
||
* on_vote_eligible returns 1 → RAFT_GRANT, voted_for==1,
|
||
* vote_eligible_call_count == 1.
|
||
*/
|
||
static void test_proto_C2_eligible_grants(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1; /* grant */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 1;
|
||
s.voted_for = -1;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_C3_eligible_null_grants
|
||
*
|
||
* on_vote_eligible is NULL (default make_mock_cbs) → treated as eligible,
|
||
* grant succeeds (RAFT_GRANT).
|
||
*/
|
||
static void test_proto_C3_eligible_null_grants(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
/* on_vote_eligible left NULL in make_mock_cbs */
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 1;
|
||
s.voted_for = -1;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
/* vote_eligible not called (NULL) */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_C4_stale_term_no_eligible_call
|
||
*
|
||
* Node at term=5, receives request at term=3 (stale).
|
||
* The stale-term check must return DENY before the policy gate is reached.
|
||
* vote_eligible_call_count must be 0.
|
||
*/
|
||
static void test_proto_C4_stale_term_no_eligible_call(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 5;
|
||
s.voted_for = -1;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_C5_already_voted_no_eligible_call
|
||
*
|
||
* voted_for=1, voted_for_term=3 at term=3.
|
||
* Receives request from idx=2 at term=3.
|
||
* The dup-vote check must fire before the policy gate.
|
||
* vote_eligible_call_count must be 0.
|
||
*/
|
||
static void test_proto_C5_already_voted_no_eligible_call(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 3;
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 3;
|
||
|
||
int rc = raft_on_vote_request(&s, 2, 3, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/* ---------- Group H: Persistence and restart (task 5.2) ---------- */
|
||
|
||
/*
|
||
* test_proto_H1_init_with_persisted_state
|
||
*
|
||
* raft_config_t with initial_term=5, initial_voted_for=1.
|
||
* After raft_init: s.term==5, s.voted_for==1, s.role==RAFT_FOLLOWER.
|
||
*/
|
||
static void test_proto_H1_init_with_persisted_state(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_term = 5;
|
||
cfg.initial_voted_for = 1;
|
||
cfg.cbs = &cbs;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
/* The restored vote belongs to the restored term — without this, the
|
||
* duplicate-vote check cannot match after restart (see H6). */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
}
|
||
|
||
/*
|
||
* test_proto_H2_init_first_boot
|
||
*
|
||
* initial_term=0, initial_voted_for=-1 (first boot).
|
||
* After raft_init: s.term==0, s.voted_for==-1, s.role==RAFT_FOLLOWER.
|
||
*/
|
||
static void test_proto_H2_init_first_boot(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_term = 0;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
}
|
||
|
||
/*
|
||
* test_proto_H6_restored_vote_blocks_second_vote
|
||
*
|
||
* Election Safety across restart: a node that voted for candidate 1 in
|
||
* term 5, then restarted (raft_init with initial_term=5, initial_voted_for=1),
|
||
* must DENY a vote request from a DIFFERENT candidate (idx=2) in the same
|
||
* term 5. Without restoring voted_for_term alongside voted_for, the
|
||
* duplicate-vote check cannot match the restored term and the node would
|
||
* grant a second vote in term 5 → two leaders in one term.
|
||
*/
|
||
static void test_proto_H6_restored_vote_blocks_second_vote(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
|
||
raft_config_t cfg;
|
||
memset(&s, 0, sizeof(s));
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_term = 5;
|
||
cfg.initial_voted_for = 1; /* persisted vote for candidate 1, term 5 */
|
||
cfg.cbs = &cbs;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
/* Different candidate (idx=2) requests a vote in the SAME term 5 */
|
||
int rc = raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* The restored vote must be untouched */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
|
||
/* Same candidate (idx=1) retransmitting must still be re-granted */
|
||
rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
}
|
||
|
||
/*
|
||
* test_proto_H3_persist_before_vote_recorded
|
||
*
|
||
* Verify that on_persist fires BEFORE voted_for is written to raft_state_t.
|
||
* A custom persist callback captures s->voted_for at call time; it must
|
||
* still be -1 (unset) when persist is invoked, and set to the candidate
|
||
* after raft_on_vote_request returns RAFT_GRANT.
|
||
*/
|
||
static raft_state_t *h3_state_ptr = NULL;
|
||
static int h3_voted_for_snapshot = 999; /* sentinel; updated by h3_persist */
|
||
|
||
static int h3_persist(void *ctx, uint64_t term, int voted_for)
|
||
{
|
||
(void)ctx; (void)term; (void)voted_for;
|
||
/* Capture voted_for from the live state object at persist call time */
|
||
if (h3_state_ptr)
|
||
h3_voted_for_snapshot = h3_state_ptr->voted_for;
|
||
g_mock.persist_count++;
|
||
return 0;
|
||
}
|
||
|
||
static void test_proto_H3_persist_before_vote_recorded(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
h3_state_ptr = &s;
|
||
h3_voted_for_snapshot = 999; /* reset sentinel */
|
||
cbs.on_persist = h3_persist;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 1;
|
||
s.voted_for = -1;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
|
||
|
||
/* Must grant */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
/* on_persist must have fired once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
|
||
/* At persist call time, voted_for must still have been -1 (not yet written) */
|
||
TEST_ASSERT_EQUAL_INT(-1, h3_voted_for_snapshot);
|
||
|
||
/* After the call returns, voted_for must be set to from_idx=1 */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
|
||
h3_state_ptr = NULL;
|
||
}
|
||
|
||
/*
|
||
* test_proto_H4_persist_fail_denies
|
||
*
|
||
* g_mock.persist_return_value = -1.
|
||
* raft_on_vote_request must return RAFT_DENY and leave voted_for==-1.
|
||
*/
|
||
static void test_proto_H4_persist_fail_denies(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
g_mock.persist_return_value = -1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.term = 1;
|
||
s.voted_for = -1;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
}
|
||
|
||
/*
|
||
* test_proto_H5_start_election_self_vote_persisted
|
||
*
|
||
* raft_start_election(s) for a 3-node cluster.
|
||
* on_persist must fire with (new_term=1, self_idx=0).
|
||
* g_mock.last_persist_voted_for == s.self_idx (0).
|
||
* g_mock.last_persist_term == 1.
|
||
*/
|
||
static void test_proto_H5_start_election_self_vote_persisted(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
int rc = raft_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
|
||
/* on_persist fired with (term=1, self_idx=0) for the self-vote */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term);
|
||
TEST_ASSERT_EQUAL_INT(s.self_idx, g_mock.last_persist_voted_for);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for);
|
||
}
|
||
|
||
/*
|
||
* test_proto_Hnull_required_callback_einval
|
||
*
|
||
* raft_init with NULL on_become_follower → returns -EINVAL.
|
||
* State remains untouched (canary check).
|
||
*/
|
||
static void test_proto_Hnull_required_callback_einval(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
|
||
memset(&s, 0xAB, sizeof(s));
|
||
|
||
cbs.on_become_follower = NULL; /* knock out a required callback */
|
||
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.cbs = &cbs;
|
||
|
||
int rc = raft_init(&s, &cfg);
|
||
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
||
|
||
/* s must be completely unchanged (canary check) */
|
||
{
|
||
unsigned char buf[sizeof(s)];
|
||
memset(buf, 0xAB, sizeof(buf));
|
||
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
|
||
}
|
||
}
|
||
|
||
/* ---------- Group B: voted_for Invariant (task 5.1) ---------- */
|
||
|
||
/*
|
||
* test_proto_B1_one_vote_per_term
|
||
*
|
||
* Node 2 receives two RPCs in the same term — one from node 0, one from node 1.
|
||
* Both RPCs are buffered (nodes 0 and 1 both start elections simultaneously).
|
||
* After delivery node 2 grants only the first requester; the second is denied.
|
||
* voted_for is set to exactly one candidate.
|
||
*/
|
||
static void test_proto_B1_one_vote_per_term(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Both node 0 and node 1 start elections — they each send to all peers */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_start_election(&c.nodes[1].state);
|
||
|
||
/* 4 RPCs buffered: node0→1, node0→2, node1→0, node1→2 */
|
||
TEST_ASSERT_EQUAL_INT(4, c.pending_count);
|
||
|
||
/* Deliver all */
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Node 2 must have voted for exactly one candidate */
|
||
TEST_ASSERT_NOT_EQUAL(-1, c.nodes[2].state.voted_for);
|
||
|
||
/* voted_for must be 0 or 1 (one of the two candidates that requested) */
|
||
int vf = c.nodes[2].state.voted_for;
|
||
TEST_ASSERT_TRUE(vf == 0 || vf == 1);
|
||
}
|
||
|
||
/*
|
||
* test_proto_B2_higher_term_updates_state
|
||
*
|
||
* Single-node at term=2, role=FOLLOWER, voted_for=1 (old vote).
|
||
* Receives vote request at term=5 (higher).
|
||
* Expected: node.term==5, voted_for updated (grant because on_vote_eligible is NULL).
|
||
*/
|
||
static void test_proto_B2_higher_term_updates_state(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 1, 2));
|
||
|
||
/* Simulate old vote from a previous term */
|
||
s.voted_for = 1; /* node voted for itself in an earlier election */
|
||
s.voted_for_term = 1; /* that vote was in term 1 */
|
||
|
||
int rc = raft_on_vote_request(&s, 0, 5, 0, 0, &reply_term);
|
||
|
||
/* Term must be upgraded to 5 */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
|
||
/* After term upgrade, voted_for is cleared to -1 and then re-evaluated.
|
||
* With on_vote_eligible=NULL and persist succeeding, must grant. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
|
||
/* voted_for must now be set to from_idx=0 */
|
||
TEST_ASSERT_EQUAL_INT(0, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
}
|
||
|
||
/*
|
||
* test_proto_B3_stale_term_denied
|
||
*
|
||
* Node at term=5. Receives request at term=3 (stale).
|
||
* Must return RAFT_DENY, reply_term==5, node.term==5 (unchanged).
|
||
*/
|
||
static void test_proto_B3_stale_term_denied(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_vote_request(&s, 0, 3, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term); /* term unchanged */
|
||
|
||
/* on_persist must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_B4_different_candidate_denied
|
||
*
|
||
* voted_for=1, voted_for_term=3 at term=3.
|
||
* Receives request from idx=2 at term=3.
|
||
* Must DENY — already voted for 1 this term.
|
||
*/
|
||
static void test_proto_B4_different_candidate_denied(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 3;
|
||
|
||
int rc = raft_on_vote_request(&s, 2, 3, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(3, reply_term);
|
||
|
||
/* voted_for must still be 1 — unchanged */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.voted_for_term);
|
||
|
||
/* on_persist must NOT fire */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_B5_idempotent_same_candidate
|
||
*
|
||
* voted_for=1, voted_for_term=3 at term=3.
|
||
* Receives request from idx=1 at term=3 again (same candidate, same term).
|
||
* Must GRANT (idempotent re-grant).
|
||
*/
|
||
static void test_proto_B5_idempotent_same_candidate(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 3;
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(3, reply_term);
|
||
|
||
/* voted_for must still be 1 */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.voted_for_term);
|
||
|
||
/* on_persist fires once for the re-grant */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
}
|
||
|
||
/* ---------- Group D: Quorum and Result (task 5.3) ---------- */
|
||
|
||
/*
|
||
* test_proto_D1_quorum_1grant_of_2_needed
|
||
*
|
||
* 3-node cluster, node 0 starts election (votes_granted=1, n_peers=3, quorum=2).
|
||
* Feed 1 grant reply from peer 1: votes_granted becomes 2 == quorum.
|
||
* Expected: RAFT_BECAME_LEADER, role==RAFT_LEADER, election_decided==1,
|
||
* on_become_leader fired exactly once.
|
||
*/
|
||
static void test_proto_D1_quorum_1grant_of_2_needed(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* Start election: votes_granted=1 (self), role=CANDIDATE */
|
||
int start_rc = raft_start_election(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, start_rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.votes_granted);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
|
||
|
||
/* Feed 1 grant from peer 1: votes_granted=2 == quorum=2 */
|
||
int rc = raft_on_vote_reply(&s, 1, 1, 1);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_leader must have fired exactly once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_D2_both_peers_deny_becomes_follower
|
||
*
|
||
* 3-node cluster, node 0 starts election (votes_granted=1, replies_received=0).
|
||
* Feed 2 deny replies from peers 1 and 2.
|
||
* After second deny: replies_received==2 == n_peers-1=2, votes_granted=1 < quorum=2.
|
||
* Expected: RAFT_BECAME_FOLLOWER, role==RAFT_FOLLOWER, election_decided==1,
|
||
* on_become_follower fired.
|
||
*/
|
||
static void test_proto_D2_both_peers_deny_becomes_follower(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* Start election */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
|
||
|
||
/* First deny from peer 1: replies_received=1, still pending */
|
||
int rc1 = raft_on_vote_reply(&s, 1, 0, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
|
||
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
|
||
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
|
||
|
||
/* Second deny from peer 2: replies_received=2 == n_peers-1=2 → step down */
|
||
int rc2 = raft_on_vote_reply(&s, 2, 0, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc2);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_follower must have fired once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_D3_second_vote_reply_noop
|
||
*
|
||
* 3-node cluster, start election.
|
||
* Feed 1 grant → RAFT_BECAME_LEADER (election_decided=1).
|
||
* Feed a second grant from peer 2 → RAFT_PENDING (noop, election_decided guard).
|
||
* Expected: become_leader_count still 1 (on_become_leader fired exactly once).
|
||
*/
|
||
static void test_proto_D3_second_vote_reply_noop(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* Start election */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
|
||
/* First grant: wins election */
|
||
int rc1 = raft_on_vote_reply(&s, 1, 1, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||
|
||
/* Second grant from peer 2: election already decided → noop */
|
||
int rc2 = raft_on_vote_reply(&s, 2, 1, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc2);
|
||
|
||
/* on_become_leader must NOT have fired again */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_D4_reply_higher_term_steps_down
|
||
*
|
||
* 3-node cluster, start election (term=1).
|
||
* Feed reply with term=3 (granted=0) → higher term → step down.
|
||
* Expected: RAFT_BECAME_FOLLOWER, role==RAFT_FOLLOWER, s.term==3,
|
||
* election_decided==1, on_become_follower fired.
|
||
*/
|
||
static void test_proto_D4_reply_higher_term_steps_down(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* Start election: term becomes 1 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(1, (int)s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
|
||
/* Reply with higher term=3, not granted */
|
||
int rc = raft_on_vote_reply(&s, 1, 0, 3);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(3, s.term);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_follower must have fired once */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
|
||
}
|
||
|
||
/* ---------- Group E: Election Timeout (task 5.3) ---------- */
|
||
|
||
/*
|
||
* test_proto_E1_timeout_before_quorum
|
||
*
|
||
* 3-node cluster, start election (on_schedule_timeout fires → schedule_timeout_count==1).
|
||
* Call raft_on_election_timeout before any replies.
|
||
* Expected: role==RAFT_FOLLOWER, election_decided==1, on_become_follower fired,
|
||
* on_cancel_timeout fired.
|
||
*/
|
||
static void test_proto_E1_timeout_before_quorum(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* Start election: schedule_timeout fires */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
|
||
|
||
/* Fire timeout before any replies */
|
||
raft_on_election_timeout(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* on_become_follower must have fired */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
|
||
|
||
/* on_cancel_timeout must have fired */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_E2_timeout_after_quorum_noop
|
||
*
|
||
* 3-node cluster, start election, feed 1 grant → RAFT_BECAME_LEADER.
|
||
* Call raft_on_election_timeout.
|
||
* Expected: role still RAFT_LEADER (already decided, timeout is noop),
|
||
* on_cancel_timeout fired (stale timer cleanup),
|
||
* on_become_follower NOT additionally fired.
|
||
*/
|
||
static void test_proto_E2_timeout_after_quorum_noop(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* Start election */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
|
||
/* Feed 1 grant → LEADER */
|
||
int rc = raft_on_vote_reply(&s, 1, 1, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* Snapshot counters before stale timeout delivery */
|
||
int follower_before = g_mock.become_follower_count;
|
||
int cancel_before = g_mock.cancel_timeout_count;
|
||
|
||
/* Fire timeout: already decided → fires on_cancel_timeout, no other effects (Req 6.3) */
|
||
raft_on_election_timeout(&s);
|
||
|
||
/* Role must still be LEADER */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
|
||
/* on_cancel_timeout fires exactly once for the stale delivery (Req 6.3) */
|
||
TEST_ASSERT_EQUAL_INT(cancel_before + 1, g_mock.cancel_timeout_count);
|
||
|
||
/* on_become_follower must NOT have fired additionally */
|
||
TEST_ASSERT_EQUAL_INT(follower_before, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_proto_E3_new_election_after_timeout
|
||
*
|
||
* 3-node cluster, start election (term=1).
|
||
* Timeout fires → becomes FOLLOWER (election_decided=1).
|
||
* Start new election: raft_start_election again.
|
||
* Expected: term==2 (incremented), on_schedule_timeout fired again
|
||
* (schedule_timeout_count==2 total).
|
||
*/
|
||
static void test_proto_E3_new_election_after_timeout(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
|
||
/* First election: term=1 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_UINT64(1, s.term);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
|
||
|
||
/* Timeout: step down to FOLLOWER */
|
||
raft_on_election_timeout(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
|
||
|
||
/* Start new election: election_decided=1 means old state decided — need to
|
||
* reset election state so raft_start_election allows another attempt.
|
||
* Per spec: a new election restarts the state machine; reset election_decided. */
|
||
s.election_decided = 0;
|
||
s.role = RAFT_FOLLOWER;
|
||
|
||
int rc2 = raft_start_election(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc2);
|
||
|
||
/* Term must have incremented to 2 */
|
||
TEST_ASSERT_EQUAL_UINT64(2, s.term);
|
||
|
||
/* on_schedule_timeout must have fired again (total 2) */
|
||
TEST_ASSERT_EQUAL_INT(2, g_mock.schedule_timeout_count);
|
||
}
|
||
|
||
/* ---------- Group F: RDHUP Failover (task 5.4) ---------- */
|
||
|
||
/*
|
||
* test_proto_F1_leader_lost_priority_delay
|
||
*
|
||
* Verify that on_start_election_after_delay fires with delay = my_priority * election_timeout_ms.
|
||
* Node 1 (priority=1, timeout=1000) → delay=1000ms.
|
||
* Node 2 (priority=2, timeout=1000) → delay=2000ms.
|
||
*/
|
||
static void test_proto_F1_leader_lost_priority_delay(void)
|
||
{
|
||
/* --- Node 1: priority=1, election_timeout_ms=1000 → expected delay=1000 --- */
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
|
||
reset_mock();
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 1;
|
||
cfg.my_priority = 1;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
raft_on_leader_lost(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
|
||
TEST_ASSERT_EQUAL_UINT32(1000, g_mock.last_start_election_delay);
|
||
}
|
||
|
||
/* --- Node 2: priority=2, election_timeout_ms=1000 → expected delay=2000 --- */
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
|
||
reset_mock();
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 2;
|
||
cfg.my_priority = 2;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
raft_on_leader_lost(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
|
||
TEST_ASSERT_EQUAL_UINT32(2000, g_mock.last_start_election_delay);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* test_proto_F2_voted_during_delay_skip
|
||
*
|
||
* Node calls raft_on_leader_lost (voted_for=-1).
|
||
* During the jitter delay, a vote request arrives from node 0 at term=1.
|
||
* Node grants the vote (voted_for=0).
|
||
* Then raft_check_and_start_election is called.
|
||
* Since voted_for==0 != self_idx==1, must return RAFT_SKIP_ELECTION.
|
||
*/
|
||
static void test_proto_F2_voted_during_delay_skip(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
uint64_t reply_term = 0;
|
||
|
||
reset_mock();
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 1;
|
||
cfg.my_priority = 1;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
/* Leader lost: clears voted_for to -1, schedules delayed election */
|
||
raft_on_leader_lost(&s);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
|
||
/* During the delay, a vote request arrives from node 0 at term=1 */
|
||
int rc = raft_on_vote_request(&s, 0, 1, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, s.voted_for);
|
||
|
||
/* Timer fires: check-and-start sees voted_for=0 != self_idx=1 → skip */
|
||
int check_rc = raft_check_and_start_election(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, check_rc);
|
||
|
||
/* Role must stay FOLLOWER — no election started */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
}
|
||
|
||
/*
|
||
* test_proto_F3_no_vote_during_delay_starts_election
|
||
*
|
||
* Node 0 (priority=0, self_idx=0) calls raft_on_leader_lost.
|
||
* No vote arrives during the delay (voted_for stays -1).
|
||
* raft_check_and_start_election must return RAFT_ELECTION_STARTED and role==CANDIDATE.
|
||
*/
|
||
static void test_proto_F3_no_vote_during_delay_starts_election(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
raft_config_t cfg;
|
||
|
||
reset_mock();
|
||
memset(&cfg, 0, sizeof(cfg));
|
||
cfg.n_peers = 3;
|
||
cfg.self_idx = 0;
|
||
cfg.my_priority = 0;
|
||
cfg.election_timeout_ms = 1000;
|
||
cfg.initial_voted_for = -1;
|
||
cfg.cbs = &cbs;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
|
||
|
||
/* Leader lost: voted_for cleared to -1 */
|
||
raft_on_leader_lost(&s);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
|
||
/* No vote arrived during the delay */
|
||
int rc = raft_check_and_start_election(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
}
|
||
|
||
/*
|
||
* test_proto_F4_simultaneous_rdhup_one_leader
|
||
*
|
||
* Nodes 1 and 2 both receive RDHUP (leader/node 0 lost).
|
||
* Both call raft_on_leader_lost → voted_for=-1 for both.
|
||
* Node 1 (lower idx, lower priority) calls check_and_start first → ELECTION_STARTED.
|
||
* Node 1 sends vote requests; deliver them to node 2.
|
||
* Node 2 grants node 1 (voted_for=1).
|
||
* Node 2 then calls check_and_start → RAFT_SKIP_ELECTION (voted_for=1 != self_idx=2).
|
||
* Result: leader_count==1.
|
||
*/
|
||
static void test_proto_F4_simultaneous_rdhup_one_leader(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Simulate: node 0 was the leader, now lost. Nodes 1 and 2 see RDHUP. */
|
||
raft_on_leader_lost(&c.nodes[1].state);
|
||
raft_on_leader_lost(&c.nodes[2].state);
|
||
|
||
/* Both have voted_for=-1 now */
|
||
TEST_ASSERT_EQUAL_INT(-1, c.nodes[1].state.voted_for);
|
||
TEST_ASSERT_EQUAL_INT(-1, c.nodes[2].state.voted_for);
|
||
|
||
/* Node 1 fires its delayed timer first (lower priority = smaller delay) */
|
||
int rc1 = raft_check_and_start_election(&c.nodes[1].state);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[1].state.role);
|
||
|
||
/* Deliver node 1's vote requests to the cluster (node 2 gets one, node 0 is gone) */
|
||
/* Only deliver the RPC intended for node 2; skip node 0 (it's gone/isolated) */
|
||
raft_sim_partition(&c, 0, 1); /* node 0 is isolated (lost leader) */
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Node 2 received node 1's vote request and granted it */
|
||
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].state.voted_for);
|
||
|
||
/* Node 2's timer fires: check_and_start sees voted_for=1 != self_idx=2 → skip */
|
||
int rc2 = raft_check_and_start_election(&c.nodes[2].state);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc2);
|
||
|
||
/* Exactly one leader (node 1) elected */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
}
|
||
|
||
/* ---------- Group G: Split-Brain Prevention (task 5.4) ---------- */
|
||
|
||
/*
|
||
* test_proto_G1_no_split_brain_100_rounds
|
||
*
|
||
* Run 100 independent election rounds on a fresh 3-node cluster each time.
|
||
* Node 0 always starts first. After delivering pending RPCs, leader_count must
|
||
* equal exactly 1 (no split brain, no starvation).
|
||
*/
|
||
static void test_proto_G1_no_split_brain_100_rounds(void)
|
||
{
|
||
int total_leaders = 0;
|
||
|
||
for (int round = 0; round < 100; round++) {
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Node 0 always starts first (highest priority, idx=0) */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Each round must produce exactly one leader — no split brain */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
total_leaders += c.leader_count;
|
||
}
|
||
|
||
/* Across 100 rounds, exactly 100 leaders must have been elected */
|
||
TEST_ASSERT_EQUAL_INT(100, total_leaders);
|
||
}
|
||
|
||
/*
|
||
* test_proto_G2_partition_minority_no_quorum
|
||
*
|
||
* 3-node cluster. Node 2 is isolated (partition of 1).
|
||
* Nodes 0+1 form the majority partition (size=2 = quorum).
|
||
* Node 0 starts election in the majority partition → wins (leader_count=1).
|
||
* Node 2 tries to start election in isolation → RPCs dropped → cannot win.
|
||
* leader_count remains 1.
|
||
*/
|
||
static void test_proto_G2_partition_minority_no_quorum(void)
|
||
{
|
||
raft_sim_cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
|
||
|
||
/* Isolate node 2 (minority partition of 1) */
|
||
raft_sim_partition(&c, 2, 1);
|
||
|
||
/* Node 0 starts election in the majority partition {0, 1} */
|
||
raft_start_election(&c.nodes[0].state);
|
||
raft_sim_deliver_pending(&c);
|
||
|
||
/* Nodes 0+1 have quorum=2; node 0 must be LEADER */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
|
||
|
||
/* Node 2 attempts to start an election in isolation */
|
||
raft_start_election(&c.nodes[2].state);
|
||
raft_sim_deliver_pending(&c); /* RPCs to/from node 2 are dropped */
|
||
|
||
/* Node 2 cannot win — still CANDIDATE (no grants received) */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[2].state.role);
|
||
|
||
/* leader_count still 1 — no split brain */
|
||
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
|
||
}
|
||
|
||
#endif /* XCE_TESTING */
|
||
|
||
/* ---------- (lastLogTerm,lastLogIndex) vote freshness tests (task 4.1) ----------
|
||
*
|
||
* raft.tla HandleRequestVoteRequest logOk (285), closes G6. The library now
|
||
* computes logOk itself from the candidate's advertised (cand_last_log_term,
|
||
* cand_last_log_index) against its own (on_get_entry_term(log_len), log_len),
|
||
* BEFORE the application gate. */
|
||
|
||
/* Configure the voter's local log: last index = log_len, last term = last_term. */
|
||
static void set_voter_log(raft_state_t *s, uint64_t log_len, uint64_t last_term)
|
||
{
|
||
s->log_len = log_len;
|
||
g_mock.get_entry_term_log_len = log_len;
|
||
g_mock.local_last_log_term = last_term;
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_freshness_stale_term_denied_higher_index
|
||
* Voter log = (term 5, index 3). Candidate advertises a STALE last-log term (4)
|
||
* but a HIGHER index (10). logOk false -> deny, even though the app gate grants.
|
||
* (Req 3.1, 3.3, 3.4.)
|
||
*/
|
||
static void test_vote_req_freshness_stale_term_denied_higher_index(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 3, 5);
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 4, 10, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_freshness_fresher_term_granted_smaller_index
|
||
* Voter log = (term 5, index 10). Candidate (term 6 fresher, index 2 smaller).
|
||
* logOk = (6 > 5) -> grant. (Req 3.1, 3.6.)
|
||
*/
|
||
static void test_vote_req_freshness_fresher_term_granted_smaller_index(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 10, 5);
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 6, 2, &reply_term);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_freshness_tie_term_index_boundary
|
||
* Voter log = (term 5, index 4). On a term TIE, grant requires cand index >=
|
||
* voter log_len. index 3 (<4) -> deny; index 4 (==4) -> grant. (Req 3.1.)
|
||
*/
|
||
static void test_vote_req_freshness_tie_term_index_boundary(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 4, 5);
|
||
int rc = raft_on_vote_request(&s, 1, 5, 5, 3, &reply_term); /* idx 3 < 4 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 4, 5);
|
||
rc = raft_on_vote_request(&s, 1, 5, 5, 4, &reply_term); /* idx 4 == 4 */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_freshness_empty_voter_log_term_zero
|
||
* Empty voter log (log_len 0): local last-log term 0. Candidate (0,0) satisfies
|
||
* logOk = (0>0)||(0==0 && 0>=0) = true -> grant. (Req 3.1.)
|
||
*/
|
||
static void test_vote_req_freshness_empty_voter_log_term_zero(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_vote_req_freshness_logok_precedes_app_gate
|
||
* !logOk denies even when on_vote_eligible WOULD grant, and the app gate is not
|
||
* consulted (logOk strictly precedes it). (Req 3.4.)
|
||
*/
|
||
static void test_vote_req_freshness_logok_precedes_app_gate(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 8, 7);
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 7, 1, &reply_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/* ---------- (lastLogTerm,lastLogIndex) PreVote freshness tests (task 5.1) ----------
|
||
*
|
||
* The PreVote path applies the IDENTICAL logOk, and mutates NOTHING. (Req 4.1-4.5.) */
|
||
|
||
/*
|
||
* test_pre_vote_req_freshness_stale_tuple_denied
|
||
* Voter (term 5, index 3). PreVote tuple (term 4 stale, index 10) -> deny, no
|
||
* mutation, no persist. (Req 4.1, 4.3.)
|
||
*/
|
||
static void test_pre_vote_req_freshness_stale_tuple_denied(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 3, 5);
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 5, 4, 10);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_freshness_fresher_tuple_granted
|
||
* Voter (term 5, index 10). PreVote (term 6 fresher, index 2) -> grant, still
|
||
* side-effect-free. (Req 4.1.)
|
||
*/
|
||
static void test_pre_vote_req_freshness_fresher_tuple_granted(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 10, 5);
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 5, 6, 2);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_freshness_no_mutation_grant_and_deny
|
||
* Across BOTH a grant and a deny, no change to term/voted_for/voted_for_term/
|
||
* role/commit_index and no on_persist. (Req 4.4.)
|
||
*/
|
||
static void test_pre_vote_req_freshness_no_mutation_grant_and_deny(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.commit_index = 2;
|
||
set_voter_log(&s, 4, 5);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, raft_on_pre_vote_request(&s, 1, 5, 5, 9));
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(2, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.commit_index = 2;
|
||
set_voter_log(&s, 4, 5);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, raft_on_pre_vote_request(&s, 1, 5, 5, 1));
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(2, s.commit_index);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_freshness_stale_term_still_denied
|
||
* The pre-existing stale-term deny stays in force even with a fresh tuple:
|
||
* cand_term < s.term denies. (Req 4.5.)
|
||
*/
|
||
static void test_pre_vote_req_freshness_stale_term_still_denied(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_get_entry_term = mock_get_entry_term;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
set_voter_log(&s, 3, 5);
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 4, 99, 99);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/* ---------- raft_on_pre_vote_request tests (task 2.1) ---------- */
|
||
|
||
/*
|
||
* raft_on_pre_vote_request is the voter-side PreVote decision. It mirrors
|
||
* raft_on_vote_request's Step 1 (stale-term deny) and Step 5 (on_vote_eligible
|
||
* gate) but strips ALL persistent mutation: it must never touch term,
|
||
* voted_for, voted_for_term, role, and must never call on_persist. Freshness is
|
||
* applied by the dispatcher BEFORE this call (same contract as the real vote).
|
||
*/
|
||
|
||
/*
|
||
* test_pre_vote_req_grant_higher_term_eligible
|
||
*
|
||
* Node at term=5 (FOLLOWER, no vote), receives PreVote for cand_term=6 with the
|
||
* eligibility callback granting (return 1). Expected: grant (1), and term /
|
||
* voted_for / voted_for_term / role all unchanged, on_persist NOT called.
|
||
*/
|
||
static void test_pre_vote_req_grant_higher_term_eligible(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1; /* eligibility grants */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 6, 0, 0);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
/* PURITY: nothing persistent may change on a granted PreVote */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
|
||
/* eligibility gate was consulted */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_grant_equal_term_eligible
|
||
*
|
||
* Equal-term candidate (cand_term == s->term) must NOT be denied by the
|
||
* stale-term gate (deny is only for cand_term < term), and is granted when
|
||
* eligibility allows. Purity invariant still holds.
|
||
*/
|
||
static void test_pre_vote_req_grant_equal_term_eligible(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 5, 0, 0); /* equal term */
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_deny_stale_term
|
||
*
|
||
* Node at term=5, receives PreVote for cand_term=4 (< term). Expected: deny (0)
|
||
* WITHOUT consulting on_vote_eligible, no term-adoption, no persist.
|
||
*/
|
||
static void test_pre_vote_req_deny_stale_term(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1; /* would grant if consulted */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 4, 0, 0); /* stale */
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
|
||
/* Stale-term denial short-circuits BEFORE the eligibility gate. */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||
|
||
/* PURITY: term must NOT be adopted, no persist. */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_deny_eligibility_rejects
|
||
*
|
||
* Fresh, higher-term candidate but the eligibility callback rejects (returns 0)
|
||
* — e.g. primary-deny / fresh-heartbeat stickiness / catch-up. Expected: deny,
|
||
* with the eligibility gate consulted, and the purity invariant preserved.
|
||
*/
|
||
static void test_pre_vote_req_deny_eligibility_rejects(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 0; /* eligibility denies */
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 6, 0, 0);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_null_eligible_grants
|
||
*
|
||
* When on_vote_eligible is NULL, a fresh higher-or-equal-term candidate is
|
||
* granted (all Raft-eligible candidates granted), with no mutation/persist.
|
||
*/
|
||
static void test_pre_vote_req_null_eligible_grants(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
/* on_vote_eligible deliberately left NULL */
|
||
reset_mock();
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_pre_vote_request(&s, 1, 6, 0, 0);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_req_grant_preserves_existing_vote (Req 2.4)
|
||
*
|
||
* A node that has already voted for someone in the current term may still GRANT
|
||
* a PreVote (PreVote does not consume / change the recorded vote). Confirms the
|
||
* existing voted_for / voted_for_term are untouched by a granted PreVote.
|
||
*/
|
||
static void test_pre_vote_req_grant_preserves_existing_vote(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_trace = mock_trace;
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
/* Node already cast its real vote for peer 1 in term 5. */
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 5;
|
||
|
||
/* A different candidate (peer 2) probes a PreVote for term 6. */
|
||
int rc = raft_on_pre_vote_request(&s, 2, 6, 0, 0);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||
|
||
/* The recorded real vote must be untouched (no consume, no overwrite). */
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/* ---------- raft_begin_pre_vote tests (task 2.2) ---------- */
|
||
|
||
/*
|
||
* raft_begin_pre_vote is the candidate-side PreVote start. It is the pre-image
|
||
* of raft_start_election: it sends a probe to each peer and tallies a self-grant,
|
||
* but STRIPS the term++/persist/role-change/timeout. Per Req 1.2/1.3/6.2 it must
|
||
* NOT modify s->term, s->voted_for, s->voted_for_term, s->role, and must NOT call
|
||
* on_persist. The prospective term (term+1) is a LOCAL value only.
|
||
*/
|
||
|
||
/*
|
||
* test_begin_pre_vote_uses_prospective_term_not_persisted
|
||
*
|
||
* 3-node FOLLOWER at term=5. raft_begin_pre_vote must send PreVote requests
|
||
* carrying prospective_term = term+1 = 6 to each peer, while s->term itself
|
||
* stays 5 and on_persist never fires.
|
||
*/
|
||
static void test_begin_pre_vote_uses_prospective_term_not_persisted(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_vote_node(&s, &cbs, 3, 0, 5));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
int rc = raft_begin_pre_vote(&s);
|
||
|
||
/* Round started (multi-node, not single) */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
|
||
/* Each peer probe carried prospective term = term+1 = 6 */
|
||
TEST_ASSERT_EQUAL_UINT64(6, g_mock.last_pre_vote_term);
|
||
|
||
/* PURITY: persistent term is unchanged (still 5) and never persisted */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_begin_pre_vote_sends_once_per_non_self_peer
|
||
*
|
||
* 3-node FOLLOWER, self=1. The send-pre-vote callback must fire exactly once
|
||
* for each peer != self (peers 0 and 2), never for self.
|
||
*/
|
||
static void test_begin_pre_vote_sends_once_per_non_self_peer(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 1, 5));
|
||
|
||
int rc = raft_begin_pre_vote(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
|
||
/* Exactly n_peers-1 = 2 sends */
|
||
TEST_ASSERT_EQUAL_INT(2, g_mock.send_pre_vote_request_count);
|
||
|
||
/* Self (idx 1) must NOT be probed; peers 0 and 2 must each be probed once. */
|
||
TEST_ASSERT_EQUAL_UINT64(0, g_mock.pre_vote_sent_term[1]); /* self untouched */
|
||
TEST_ASSERT_EQUAL_UINT64(6, g_mock.pre_vote_sent_term[0]);
|
||
TEST_ASSERT_EQUAL_UINT64(6, g_mock.pre_vote_sent_term[2]);
|
||
|
||
/* None of the ordered sent peers is self_idx=1 */
|
||
for (int i = 0; i < g_mock.send_pre_vote_request_count; i++)
|
||
TEST_ASSERT_NOT_EQUAL(1, g_mock.pre_vote_sent_peers[i]);
|
||
}
|
||
|
||
/*
|
||
* test_begin_pre_vote_self_grant_and_in_progress
|
||
*
|
||
* After begin, the PreVote tally must show in_progress=1 and a single self-grant
|
||
* (pre_votes_granted=1). No real election timeout is scheduled here (the
|
||
* controller arms the round timeout separately).
|
||
*/
|
||
static void test_begin_pre_vote_self_grant_and_in_progress(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_begin_pre_vote(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self-grant only */
|
||
|
||
/* No election-timeout scheduled by begin_pre_vote (controller owns it). */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
|
||
}
|
||
|
||
/*
|
||
* test_begin_pre_vote_purity_no_persistent_mutation
|
||
*
|
||
* Byte-for-byte: term, voted_for, voted_for_term, role unchanged; no persist,
|
||
* no role transition to CANDIDATE, no become_leader/become_follower.
|
||
*/
|
||
static void test_begin_pre_vote_purity_no_persistent_mutation(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
/* Node already cast a real vote for peer 2 in term 5 — must survive. */
|
||
s.voted_for = 2;
|
||
s.voted_for_term = 5;
|
||
|
||
int rc = raft_begin_pre_vote(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
|
||
/* PURITY: every persistent field untouched */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(2, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stays FOLLOWER, not CANDIDATE */
|
||
|
||
/* No persistence / no leadership-transition side effects */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_begin_pre_vote_single_node_wins
|
||
*
|
||
* 1-node cluster: no peers, the self-grant is already a majority, so the round
|
||
* is won immediately. Returns RAFT_PRE_VOTE_WON, sends nothing, and still
|
||
* mutates no persistent state.
|
||
*/
|
||
static void test_begin_pre_vote_single_node_wins(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 1, 0, 5));
|
||
|
||
int rc = raft_begin_pre_vote(&s);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
|
||
|
||
/* No peer probes for a single-node cluster */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.send_pre_vote_request_count);
|
||
|
||
/* Self-grant recorded as a majority */
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted);
|
||
|
||
/* PURITY even on the WON short-circuit */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
}
|
||
|
||
/*
|
||
* test_begin_pre_vote_null_send_callback_suppressed
|
||
*
|
||
* If on_send_pre_vote_request is NULL, begin must still run (the caller owns
|
||
* transport) without crashing, recording the self-grant, and emitting no sends.
|
||
*/
|
||
static void test_begin_pre_vote_null_send_callback_suppressed(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
cbs.on_send_pre_vote_request = NULL; /* suppressed */
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_begin_pre_vote(&s);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.send_pre_vote_request_count);
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
}
|
||
|
||
/* ---------- raft_on_pre_vote_reply tally (task 2.3) ---------- */
|
||
|
||
/*
|
||
* Helper: arm a pre-vote round on an n-node cluster at the given term.
|
||
* Mirrors what raft_begin_pre_vote sets up (self-grant, in-progress), so the
|
||
* tally tests can feed replies without depending on the send transport.
|
||
*/
|
||
static int arm_pre_vote_round(raft_state_t *s, raft_callbacks_t *cbs,
|
||
int n_peers, int self_idx, uint64_t term)
|
||
{
|
||
int rc = init_vote_node(s, cbs, n_peers, self_idx, term);
|
||
if (rc != 0)
|
||
return rc;
|
||
/* Suppress sends; we drive replies directly. */
|
||
raft_begin_pre_vote(s);
|
||
return 0;
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_won_at_quorum
|
||
*
|
||
* 3-node cluster (quorum=2), self-grant already counted. One peer grants →
|
||
* the round is WON. Tally lives on the raft state; in-progress is cleared.
|
||
*/
|
||
static void test_pre_vote_reply_won_at_quorum(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
|
||
/* begin sent to peers; clear send counter so it does not confuse asserts. */
|
||
|
||
int rc = raft_on_pre_vote_reply(&s, 1, 1 /* granted */);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
|
||
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* self + peer 1 */
|
||
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress); /* round closed */
|
||
|
||
/* CRITICAL: a win must NEVER fire leadership callbacks. */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_lost_when_all_reply_no_quorum
|
||
*
|
||
* 3-node cluster. Both peers deny. After all non-self peers reply without
|
||
* quorum, the round is LOST.
|
||
*/
|
||
static void test_pre_vote_reply_lost_when_all_reply_no_quorum(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc1 = raft_on_pre_vote_reply(&s, 1, 0 /* deny */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc1); /* one peer still out */
|
||
|
||
int rc2 = raft_on_pre_vote_reply(&s, 2, 0 /* deny */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST, rc2); /* all replied, no quorum */
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only */
|
||
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_pending_when_undecided
|
||
*
|
||
* 3-node cluster. One peer denies, the other has not replied yet → PENDING
|
||
* (quorum still reachable).
|
||
*/
|
||
static void test_pre_vote_reply_pending_when_undecided(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc = raft_on_pre_vote_reply(&s, 1, 0 /* deny */);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc);
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only */
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress); /* still open */
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_duplicate_ignored
|
||
*
|
||
* A duplicate reply from the same peer must not double-count. A grant from
|
||
* peer 1, then a second grant from peer 1, leaves the tally at 2 (self + one
|
||
* grant). In a 5-node cluster (quorum=3) that keeps the round PENDING.
|
||
*/
|
||
static void test_pre_vote_reply_duplicate_ignored(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 5, 0, 5));
|
||
|
||
int rc1 = raft_on_pre_vote_reply(&s, 1, 1 /* grant */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc1);
|
||
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* self + peer 1 */
|
||
|
||
/* Duplicate grant from peer 1 — must be ignored. */
|
||
int rc2 = raft_on_pre_vote_reply(&s, 1, 1 /* grant again */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc2);
|
||
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* unchanged: no double-count */
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_won_purity_state_unchanged
|
||
*
|
||
* On WON: term, voted_for, voted_for_term, role are all unchanged and no
|
||
* persist / leadership callback fires.
|
||
*/
|
||
static void test_pre_vote_reply_won_purity_state_unchanged(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
|
||
/* A real vote cast earlier in this term must survive the tally. */
|
||
s.voted_for = 2;
|
||
s.voted_for_term = 5;
|
||
|
||
int rc = raft_on_pre_vote_reply(&s, 1, 1 /* grant */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
|
||
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(2, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_lost_purity_state_unchanged
|
||
*
|
||
* On LOST: term / voted_for / role unchanged; no leadership callback.
|
||
*/
|
||
static void test_pre_vote_reply_lost_purity_state_unchanged(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 7));
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 7;
|
||
|
||
raft_on_pre_vote_reply(&s, 1, 0 /* deny */);
|
||
int rc = raft_on_pre_vote_reply(&s, 2, 0 /* deny */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST, rc);
|
||
|
||
TEST_ASSERT_EQUAL_UINT64(7, s.term);
|
||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(7, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_out_of_range_peer_ignored
|
||
*
|
||
* A peer_idx outside [0, n_peers) (or >= RAFT_MAX_PEERS) is ignored: no tally
|
||
* change, round stays in progress, returns PENDING.
|
||
*/
|
||
static void test_pre_vote_reply_out_of_range_peer_ignored(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
|
||
|
||
int rc_neg = raft_on_pre_vote_reply(&s, -1, 1 /* grant */);
|
||
int rc_over = raft_on_pre_vote_reply(&s, 3, 1 /* grant */); /* == n_peers */
|
||
int rc_max = raft_on_pre_vote_reply(&s, RAFT_MAX_PEERS, 1);
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_neg);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_over);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_max);
|
||
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only, no spurious count */
|
||
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
|
||
}
|
||
|
||
/*
|
||
* test_pre_vote_reply_5node_won_at_three
|
||
*
|
||
* 5-node cluster (quorum=3): self + two granting peers → WON.
|
||
*/
|
||
static void test_pre_vote_reply_5node_won_at_three(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 5, 0, 5));
|
||
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING,
|
||
raft_on_pre_vote_reply(&s, 1, 1));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON,
|
||
raft_on_pre_vote_reply(&s, 2, 1));
|
||
|
||
TEST_ASSERT_EQUAL_INT(3, s.pre_votes_granted);
|
||
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/* ====================================================================== *
|
||
* Task 6.1 — Raft safety-invariant assertions for the PreVote API
|
||
*
|
||
* The per-function tests above (tasks 2.1/2.2/2.3) check individual cases.
|
||
* The tests below assert the SAFETY INVARIANTS as *properties* that hold
|
||
* across the ENTIRE PreVote API, mechanically backing the RN-1 TLA+/
|
||
* verification-checklist consistency argument recorded in
|
||
* .kiro/specs/ha-prevote/research.md (§"RN-1 resolved").
|
||
*
|
||
* ---------------------------------------------------------------------- *
|
||
* RN-1 CONSISTENCY ARGUMENT (mechanically evidenced below)
|
||
* ---------------------------------------------------------------------- *
|
||
* Claim: adding the PreVote phase preserves every Raft safety invariant
|
||
* the repository's `raft/raft.tla` reference and the controller
|
||
* verification checklist require — Election Safety (at most one leader per
|
||
* term), Leader Completeness, term monotonicity, and a monotonic commit
|
||
* index — so the system stays consistent after the change.
|
||
*
|
||
* Proof structure (purity ⇒ no new transitions):
|
||
*
|
||
* 1. The three PreVote functions — raft_begin_pre_vote,
|
||
* raft_on_pre_vote_request, raft_on_pre_vote_reply — are PURE with
|
||
* respect to the persistent Raft state. Across an arbitrary sequence of
|
||
* begin / request / reply calls (mixed grant+deny, duplicates,
|
||
* out-of-range peers, and both WON and LOST terminal outcomes), none of
|
||
* them ever:
|
||
* - changes s->term, s->voted_for, s->voted_for_term, or s->role, or
|
||
* - invokes on_persist, or
|
||
* - invokes on_become_leader / on_become_follower.
|
||
* (Asserted by test_invariant_prevote_sequence_pure_no_persist and
|
||
* test_invariant_prevote_won_grants_no_leadership.)
|
||
*
|
||
* 2. Because the PreVote path mutates no term, it cannot violate term
|
||
* monotonicity: the only writer of s->term remains raft_start_election
|
||
* (real term bump) and raft_on_vote_request Step 2 (adopt a strictly
|
||
* higher term) — both UNCHANGED by this feature. A PreVote can only
|
||
* *gate* whether raft_start_election is later called; it never advances
|
||
* a term itself. (Asserted by the term-constant checks below.)
|
||
*
|
||
* 3. Because the PreVote path creates no leader (no on_become_leader, no
|
||
* role := RAFT_LEADER), it adds no new way to satisfy the antecedent of
|
||
* Election Safety. The sole leadership-creating transitions remain
|
||
* raft_start_election's single-node short-circuit and
|
||
* raft_on_vote_reply's quorum branch — again UNCHANGED. A PreVote round
|
||
* reaching RAFT_PRE_VOTE_WON returns a STATUS only; the node stays
|
||
* RAFT_FOLLOWER until the separate, unchanged real-election path runs.
|
||
* (Asserted by test_invariant_prevote_won_grants_no_leadership.)
|
||
*
|
||
* 4. The legitimate higher-term-adopt failover path (the bug-339/bug-340
|
||
* fix) lives entirely in raft_on_vote_request / raft_on_vote_reply,
|
||
* which this feature does not touch. PreVote sits strictly *before* that
|
||
* path and, by (1), leaves its inputs (term/voted_for/role) untouched.
|
||
* test_invariant_real_vote_higher_term_adopt_unchanged demonstrates the
|
||
* real-vote path still adopts a higher term and fires become_follower /
|
||
* become_leader exactly as before, even after a full PreVote round has
|
||
* run on the same node — so PreVote did not alter the legitimate
|
||
* failover semantics bug-339/bug-340 depend on.
|
||
*
|
||
* Conclusion: PreVote introduces no transition that writes a term or
|
||
* creates a leader. Every term-writing / leadership-creating transition in
|
||
* the model is byte-for-byte the pre-PreVote transition. Therefore the set
|
||
* of reachable (term, leader) configurations is unchanged, and the TLA+
|
||
* invariants (Election Safety, Leader Completeness, term monotonicity,
|
||
* monotonic commit index) and the controller verification checklist
|
||
* continue to hold. The tests below are the mechanical evidence for
|
||
* premises (1)–(4).
|
||
* ====================================================================== */
|
||
|
||
/*
|
||
* test_invariant_prevote_sequence_pure_no_persist (Req 6.2, 2.3, 1.2)
|
||
*
|
||
* PROPERTY (term monotonicity / no persistent mutation): drive a long,
|
||
* mixed sequence of PreVote operations through a node and assert that across
|
||
* the WHOLE sequence the persistent Raft state (term, voted_for,
|
||
* voted_for_term, role) never changes and on_persist is NEVER called.
|
||
*
|
||
* The sequence deliberately exercises: begin → answer an incoming request
|
||
* (grant) → answer a stale-term request (deny) → reply tally with a grant,
|
||
* a duplicate of that grant, an out-of-range peer, and a deny — i.e. every
|
||
* PreVote entry point and both grant/deny outcomes, including a WON
|
||
* terminal. If ANY PreVote code path leaked a term/vote/role write or a
|
||
* persist, this property fails — which would be a real Election-Safety /
|
||
* term-monotonicity finding, not a test bug.
|
||
*/
|
||
static void test_invariant_prevote_sequence_pure_no_persist(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
cbs.on_vote_eligible = mock_vote_eligible;
|
||
cbs.on_trace = mock_trace;
|
||
reset_mock();
|
||
g_mock.vote_eligible_return_value = 1; /* grant when consulted */
|
||
|
||
/* 3-node FOLLOWER at term=5, having voted for peer 1 in an earlier term.
|
||
* These persistent values are the canary: nothing below may change them. */
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||
s.voted_for = 1;
|
||
s.voted_for_term = 4;
|
||
|
||
const uint64_t term0 = s.term;
|
||
const int voted_for0 = s.voted_for;
|
||
const uint64_t voted_for_term0 = s.voted_for_term;
|
||
const raft_role_t role0 = s.role;
|
||
|
||
/* (a) begin a round — self-grant + prospective term, no persist */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_begin_pre_vote(&s));
|
||
|
||
/* (b) answer an incoming PreVote request that PASSES eligibility — grant */
|
||
TEST_ASSERT_EQUAL_INT(1, raft_on_pre_vote_request(&s, 2, 6 /* higher */, 0, 0));
|
||
|
||
/* (c) answer a STALE-term incoming request — deny */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_on_pre_vote_request(&s, 2, 3 /* stale */, 0, 0));
|
||
|
||
/* (d) answer an incoming request the eligibility gate REJECTS — deny */
|
||
g_mock.vote_eligible_return_value = 0;
|
||
TEST_ASSERT_EQUAL_INT(0, raft_on_pre_vote_request(&s, 2, 6, 0, 0));
|
||
g_mock.vote_eligible_return_value = 1;
|
||
|
||
/* (e) tally replies: a grant (drives to WON quorum on 3-node), then a
|
||
* duplicate of the same grant, an out-of-range peer, and a deny. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON,
|
||
raft_on_pre_vote_reply(&s, 1, 1 /* grant */));
|
||
/* round closed after WON; further replies are no-ops on persistent state */
|
||
(void)raft_on_pre_vote_reply(&s, 1, 1 /* duplicate grant */);
|
||
(void)raft_on_pre_vote_reply(&s, 99, 1 /* out-of-range */);
|
||
(void)raft_on_pre_vote_reply(&s, 2, 0 /* late deny */);
|
||
|
||
/* INVARIANT: persistent Raft state byte-for-byte unchanged. */
|
||
TEST_ASSERT_EQUAL_UINT64(term0, s.term);
|
||
TEST_ASSERT_EQUAL_INT(voted_for0, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(voted_for_term0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(role0, s.role);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* never promoted */
|
||
|
||
/* INVARIANT: PreVote never persists and never fires leadership callbacks. */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_invariant_prevote_lost_sequence_pure_no_persist (Req 6.2, 1.4)
|
||
*
|
||
* Same purity property as above, but driving the round to the LOST terminal
|
||
* outcome (all peers deny). A WON path and a LOST path are the two ways a
|
||
* round can end; both must be side-effect-free. This guards the case where
|
||
* a frozen-then-resumed standby gets no PreVote majority (Req 4) — the round
|
||
* must leave term/vote/role untouched so the node simply re-attaches.
|
||
*/
|
||
static void test_invariant_prevote_lost_sequence_pure_no_persist(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_vote_node(&s, &cbs, 3, 0, 9));
|
||
s.voted_for = 0;
|
||
s.voted_for_term = 9;
|
||
|
||
const uint64_t term0 = s.term;
|
||
const int voted_for0 = s.voted_for;
|
||
const uint64_t voted_for_term0 = s.voted_for_term;
|
||
const raft_role_t role0 = s.role;
|
||
|
||
TEST_ASSERT_EQUAL_INT(0, raft_begin_pre_vote(&s));
|
||
|
||
/* both peers deny → LOST, no majority */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING,
|
||
raft_on_pre_vote_reply(&s, 1, 0));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST,
|
||
raft_on_pre_vote_reply(&s, 2, 0));
|
||
|
||
/* INVARIANT: persistent state unchanged across a LOST round. */
|
||
TEST_ASSERT_EQUAL_UINT64(term0, s.term);
|
||
TEST_ASSERT_EQUAL_INT(voted_for0, s.voted_for);
|
||
TEST_ASSERT_EQUAL_UINT64(voted_for_term0, s.voted_for_term);
|
||
TEST_ASSERT_EQUAL_INT(role0, s.role);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
}
|
||
|
||
/*
|
||
* test_invariant_prevote_won_grants_no_leadership (Req 6.1)
|
||
*
|
||
* ELECTION SAFETY property: a PreVote round that reaches RAFT_PRE_VOTE_WON
|
||
* grants NO leadership. The node must remain RAFT_FOLLOWER and no
|
||
* on_become_leader / on_become_follower callback may fire — a PreVote WON is
|
||
* a STATUS the controller acts on, not a leadership transition. Only the
|
||
* separate, unchanged raft_start_election path creates a leader.
|
||
*
|
||
* The test then demonstrates the contrast: invoking the real
|
||
* raft_start_election (the only leadership-creating path) from the same
|
||
* still-FOLLOWER node bumps the term and self-votes — proving the leader is
|
||
* created exclusively by the real path, never by the PreVote WON itself.
|
||
*/
|
||
static void test_invariant_prevote_won_grants_no_leadership(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
|
||
reset_mock();
|
||
/* arm a round on a 3-node FOLLOWER at term=5 (self-grant in progress) */
|
||
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
|
||
|
||
const uint64_t term_before_won = s.term;
|
||
|
||
/* one peer grant → WON on a 3-node cluster (quorum=2) */
|
||
int rc = raft_on_pre_vote_reply(&s, 1, 1 /* grant */);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
|
||
|
||
/* INVARIANT (Election Safety): WON created NO leader. */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* still a follower */
|
||
TEST_ASSERT_EQUAL_UINT64(term_before_won, s.term); /* term not advanced */
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
|
||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||
|
||
/* CONTRAST: leadership is created ONLY by the unchanged real path.
|
||
* Starting the real election now bumps the term + records the self-vote
|
||
* (this is the sole term-writing / leadership-creating transition). */
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
|
||
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(term_before_won + 1, s.term); /* real bump here */
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count); /* persisted here */
|
||
}
|
||
|
||
/*
|
||
* test_invariant_real_vote_higher_term_adopt_unchanged (Req 5.2, 5.3)
|
||
*
|
||
* REAL-VOTE-PATH-UNCHANGED property: PreVote must not have altered the
|
||
* legitimate higher-term-adopt failover path that the bug-339/bug-340 fixes
|
||
* depend on. After running a full PreVote round on a node, the real
|
||
* raft_on_vote_request still adopts a strictly higher term and steps a
|
||
* leader down (fires on_become_follower), and raft_on_vote_reply still wins
|
||
* at quorum (fires on_become_leader) — exactly as before PreVote existed.
|
||
*
|
||
* This is the library-level guard for bug-339 (higher-term adopt on
|
||
* RDHUP failover) and bug-340 (rejoin), confirming PreVote sits beside the
|
||
* real-vote path without modifying it.
|
||
*/
|
||
static void test_invariant_real_vote_higher_term_adopt_unchanged(void)
|
||
{
|
||
raft_state_t s;
|
||
raft_callbacks_t cbs = make_mock_cbs();
|
||
uint64_t reply_term = 0;
|
||
|
||
cbs.on_trace = mock_trace;
|
||
|
||
/* --- Part 1: higher-term real RequestVote adopts term + steps down a
|
||
* leader, even after a PreVote round ran on this node. --- */
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 2));
|
||
|
||
/* Run a (lost) PreVote round first to prove it leaves the real-vote
|
||
* adopt path untouched. */
|
||
TEST_ASSERT_EQUAL_INT(0, raft_begin_pre_vote(&s));
|
||
(void)raft_on_pre_vote_reply(&s, 1, 0);
|
||
(void)raft_on_pre_vote_reply(&s, 2, 0);
|
||
TEST_ASSERT_EQUAL_UINT64(2, s.term); /* PreVote did not move the term */
|
||
|
||
/* Now this node is the leader in term 2 and a real higher-term vote
|
||
* request arrives — the unchanged Step 2 must adopt term 5 and step down. */
|
||
s.role = RAFT_LEADER;
|
||
s.voted_for = 0;
|
||
s.voted_for_term = 2;
|
||
reset_mock();
|
||
|
||
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
|
||
|
||
/* Higher-term adopt + leader step-down, exactly as bug-339/340 require. */
|
||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count); /* stepped down */
|
||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc); /* then grants */
|
||
|
||
/* --- Part 2: real raft_on_vote_reply still elects a leader at quorum,
|
||
* unaffected by PreVote. --- */
|
||
reset_mock();
|
||
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
|
||
s.role = RAFT_CANDIDATE;
|
||
s.term = 1;
|
||
s.votes_granted = 1; /* self-vote */
|
||
s.replies_received = 0;
|
||
s.election_decided = 0;
|
||
|
||
int rcr = raft_on_vote_reply(&s, 1, 1 /* grant */, 1);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rcr);
|
||
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
|
||
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count); /* real path elects */
|
||
}
|
||
|
||
/* ---------- test_raft entry point ---------- */
|
||
|
||
void test_raft(void)
|
||
{
|
||
printf("\n>> raft test\n\n");
|
||
|
||
RUN_TEST(test_raft_init_null_send_vote_request_returns_einval);
|
||
RUN_TEST(test_raft_init_null_become_leader_returns_einval);
|
||
RUN_TEST(test_raft_init_null_become_follower_returns_einval);
|
||
RUN_TEST(test_raft_init_null_persist_returns_einval);
|
||
RUN_TEST(test_raft_init_null_schedule_timeout_returns_einval);
|
||
RUN_TEST(test_raft_init_null_cancel_timeout_returns_einval);
|
||
RUN_TEST(test_raft_init_null_start_election_after_delay_returns_einval);
|
||
RUN_TEST(test_raft_init_null_persist_entry_returns_einval);
|
||
RUN_TEST(test_raft_init_null_get_entry_term_returns_einval);
|
||
RUN_TEST(test_raft_init_null_send_append_entries_returns_einval);
|
||
RUN_TEST(test_raft_init_null_truncate_from_returns_einval);
|
||
RUN_TEST(test_raft_init_log_callbacks_present_succeeds);
|
||
RUN_TEST(test_raft_init_null_commit_advanced_returns_einval);
|
||
RUN_TEST(test_raft_init_commit_advanced_present_succeeds);
|
||
RUN_TEST(test_raft_init_restores_commit_index);
|
||
RUN_TEST(test_raft_init_commit_index_default_zero);
|
||
RUN_TEST(test_raft_init_even_peers_returns_einval);
|
||
RUN_TEST(test_raft_init_oversize_peers_returns_einval);
|
||
RUN_TEST(test_raft_init_zero_peers_returns_einval);
|
||
RUN_TEST(test_raft_init_success_fields);
|
||
RUN_TEST(test_raft_init_default_election_timeout);
|
||
RUN_TEST(test_raft_init_optional_callbacks_null_succeeds);
|
||
RUN_TEST(test_raft_snapshot_returns_current_state);
|
||
|
||
/* raft_start_election (task 3.2) */
|
||
RUN_TEST(test_start_election_from_follower_3node);
|
||
RUN_TEST(test_start_election_single_node);
|
||
RUN_TEST(test_start_election_already_candidate);
|
||
RUN_TEST(test_start_election_already_leader);
|
||
|
||
/* raft_on_vote_reply (task 3.4) */
|
||
RUN_TEST(test_vote_reply_higher_term_steps_down);
|
||
RUN_TEST(test_vote_reply_noop_after_decided);
|
||
RUN_TEST(test_vote_reply_quorum_3node_1grant);
|
||
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_leader_progress_init_single_node);
|
||
RUN_TEST(test_leader_progress_init_multi_node);
|
||
|
||
/* raft_log_append (task 3.1/3.2) */
|
||
RUN_TEST(test_log_append_leader_assigns_index_stamps_term);
|
||
RUN_TEST(test_log_append_follower_rejected);
|
||
RUN_TEST(test_log_append_candidate_rejected);
|
||
RUN_TEST(test_log_append_persist_failure_propagates);
|
||
|
||
/* raft_send_append_entries (task 4.1/4.2) */
|
||
RUN_TEST(test_send_ae_prev_index_zero_heartbeat);
|
||
RUN_TEST(test_send_ae_heartbeat_when_next_past_log);
|
||
RUN_TEST(test_send_ae_one_entry_when_next_within_log);
|
||
RUN_TEST(test_send_ae_non_leader_emits_nothing);
|
||
|
||
/* raft_on_append_entries_request (tasks 5.1+5.2 UpdateTerm-first, 6.1 logOk
|
||
* +reject+candidate-step-down, 6.2+6.3 accept already-have/conflict/append) */
|
||
RUN_TEST(test_ae_req_follower_adopts_higher_term);
|
||
RUN_TEST(test_ae_req_leader_adopts_higher_term_steps_down);
|
||
RUN_TEST(test_ae_req_heartbeat_adopts_higher_term);
|
||
RUN_TEST(test_ae_req_equal_term_no_term_change);
|
||
RUN_TEST(test_ae_req_stale_term_rejected);
|
||
RUN_TEST(test_ae_req_logok_prev_zero_accepts_append);
|
||
RUN_TEST(test_ae_req_logok_match_accepts);
|
||
RUN_TEST(test_ae_req_logok_mismatch_rejected);
|
||
RUN_TEST(test_ae_req_candidate_equal_term_steps_down);
|
||
RUN_TEST(test_ae_req_candidate_equal_term_steps_down_then_rejects);
|
||
RUN_TEST(test_ae_req_already_have_heartbeat);
|
||
RUN_TEST(test_ae_req_already_have_matching_term);
|
||
RUN_TEST(test_ae_req_conflict_truncate_then_persist);
|
||
RUN_TEST(test_ae_req_append_extends_log);
|
||
RUN_TEST(test_ae_req_leader_commit_not_acted);
|
||
|
||
/* raft_on_append_entries_reply (task 7.1/7.2) */
|
||
RUN_TEST(test_ae_reply_success_advances_indices);
|
||
RUN_TEST(test_ae_reply_failure_decrements_next_index_floor);
|
||
RUN_TEST(test_ae_reply_stale_term_ignored);
|
||
|
||
/* raft_advance_commit (task 3.1/3.2/3.3, AdvanceCommitIndex + Figure-8 rule) */
|
||
RUN_TEST(test_advance_commit_advances_current_term);
|
||
RUN_TEST(test_advance_commit_figure8_hold_previous_term);
|
||
RUN_TEST(test_advance_commit_no_quorum);
|
||
RUN_TEST(test_advance_commit_monotonic);
|
||
RUN_TEST(test_advance_commit_non_leader_eperm);
|
||
RUN_TEST(test_ae_reply_does_not_advance_commit);
|
||
|
||
/* raft_on_vote_request (task 3.3) */
|
||
RUN_TEST(test_vote_req_stale_term);
|
||
RUN_TEST(test_vote_req_higher_term_updates_state);
|
||
RUN_TEST(test_vote_req_same_term_already_voted_different);
|
||
RUN_TEST(test_vote_req_same_term_idempotent_same_candidate);
|
||
RUN_TEST(test_vote_req_leader_denies);
|
||
RUN_TEST(test_vote_req_on_vote_eligible_denies);
|
||
RUN_TEST(test_vote_req_on_vote_eligible_grants);
|
||
RUN_TEST(test_vote_req_on_vote_eligible_null);
|
||
RUN_TEST(test_vote_req_persist_fails);
|
||
RUN_TEST(test_vote_req_reply_term_always_set);
|
||
|
||
/* (lastLogTerm,lastLogIndex) binding-vote freshness (task 4.1, Req 3.1-3.6) */
|
||
RUN_TEST(test_vote_req_freshness_stale_term_denied_higher_index);
|
||
RUN_TEST(test_vote_req_freshness_fresher_term_granted_smaller_index);
|
||
RUN_TEST(test_vote_req_freshness_tie_term_index_boundary);
|
||
RUN_TEST(test_vote_req_freshness_empty_voter_log_term_zero);
|
||
RUN_TEST(test_vote_req_freshness_logok_precedes_app_gate);
|
||
|
||
/* (lastLogTerm,lastLogIndex) PreVote freshness (task 5.1, Req 4.1-4.5) */
|
||
RUN_TEST(test_pre_vote_req_freshness_stale_tuple_denied);
|
||
RUN_TEST(test_pre_vote_req_freshness_fresher_tuple_granted);
|
||
RUN_TEST(test_pre_vote_req_freshness_no_mutation_grant_and_deny);
|
||
RUN_TEST(test_pre_vote_req_freshness_stale_term_still_denied);
|
||
|
||
/* raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election (task 3.5) */
|
||
RUN_TEST(test_election_timeout_undecided);
|
||
RUN_TEST(test_election_timeout_already_decided);
|
||
RUN_TEST(test_leader_lost_clears_voted_for);
|
||
RUN_TEST(test_check_and_start_voted_for_other);
|
||
RUN_TEST(test_check_and_start_not_voted);
|
||
RUN_TEST(test_check_and_start_voted_for_self);
|
||
|
||
/* raft_on_pre_vote_request (task 2.1) */
|
||
RUN_TEST(test_pre_vote_req_grant_higher_term_eligible);
|
||
RUN_TEST(test_pre_vote_req_grant_equal_term_eligible);
|
||
RUN_TEST(test_pre_vote_req_deny_stale_term);
|
||
RUN_TEST(test_pre_vote_req_deny_eligibility_rejects);
|
||
RUN_TEST(test_pre_vote_req_null_eligible_grants);
|
||
RUN_TEST(test_pre_vote_req_grant_preserves_existing_vote);
|
||
|
||
/* raft_begin_pre_vote (task 2.2) */
|
||
RUN_TEST(test_begin_pre_vote_uses_prospective_term_not_persisted);
|
||
RUN_TEST(test_begin_pre_vote_sends_once_per_non_self_peer);
|
||
RUN_TEST(test_begin_pre_vote_self_grant_and_in_progress);
|
||
RUN_TEST(test_begin_pre_vote_purity_no_persistent_mutation);
|
||
RUN_TEST(test_begin_pre_vote_single_node_wins);
|
||
RUN_TEST(test_begin_pre_vote_null_send_callback_suppressed);
|
||
|
||
/* raft_on_pre_vote_reply tally (task 2.3) */
|
||
RUN_TEST(test_pre_vote_reply_won_at_quorum);
|
||
RUN_TEST(test_pre_vote_reply_lost_when_all_reply_no_quorum);
|
||
RUN_TEST(test_pre_vote_reply_pending_when_undecided);
|
||
RUN_TEST(test_pre_vote_reply_duplicate_ignored);
|
||
RUN_TEST(test_pre_vote_reply_won_purity_state_unchanged);
|
||
RUN_TEST(test_pre_vote_reply_lost_purity_state_unchanged);
|
||
RUN_TEST(test_pre_vote_reply_out_of_range_peer_ignored);
|
||
RUN_TEST(test_pre_vote_reply_5node_won_at_three);
|
||
|
||
/* Raft safety-invariant assertions for the PreVote API (task 6.1, RN-1) */
|
||
RUN_TEST(test_invariant_prevote_sequence_pure_no_persist);
|
||
RUN_TEST(test_invariant_prevote_lost_sequence_pure_no_persist);
|
||
RUN_TEST(test_invariant_prevote_won_grants_no_leadership);
|
||
RUN_TEST(test_invariant_real_vote_higher_term_adopt_unchanged);
|
||
|
||
/* simulator init (task 4.1) */
|
||
#ifdef XCE_TESTING
|
||
/* conformance harness data types (raft-sim-conformance task 1.1) */
|
||
RUN_TEST(test_sim_conformance_types_compile);
|
||
|
||
RUN_TEST(test_sim_init_3node);
|
||
RUN_TEST(test_sim_trace_buf);
|
||
|
||
/* simulator network operations (task 4.2) */
|
||
RUN_TEST(test_sim_3node_election_one_leader);
|
||
RUN_TEST(test_sim_start_all_elections_no_winner);
|
||
RUN_TEST(test_sim_partition_no_quorum);
|
||
RUN_TEST(test_sim_fire_timeout_steps_down);
|
||
RUN_TEST(test_sim_deliver_append_entries_replicates);
|
||
RUN_TEST(test_sim_check_log_matching_pass_on_converged);
|
||
RUN_TEST(test_sim_check_log_matching_fail_on_divergence);
|
||
RUN_TEST(test_sim_3_2_log_matching_shared_idx_term_requires_identical_prefix);
|
||
RUN_TEST(test_sim_check_election_safety_fail_on_two_leaders);
|
||
RUN_TEST(test_sim_check_election_safety_pass_on_single_leader);
|
||
RUN_TEST(test_sim_check_leader_completeness_fail_on_missing_committed);
|
||
RUN_TEST(test_sim_check_leader_completeness_pass_when_present);
|
||
RUN_TEST(test_sim_check_state_machine_safety_fail_on_divergence);
|
||
RUN_TEST(test_sim_check_state_machine_safety_pass_on_agreement);
|
||
RUN_TEST(test_sim_check_all_first_failure_is_election_safety);
|
||
RUN_TEST(test_sim_check_all_reaches_later_invariant);
|
||
RUN_TEST(test_sim_check_all_clean_returns_none);
|
||
RUN_TEST(test_sim_9_1_replication_and_partition_catch_up);
|
||
RUN_TEST(test_sim_9_2_divergent_suffix_truncation);
|
||
RUN_TEST(test_sim_9_3_drop_dup_reorder_convergence);
|
||
RUN_TEST(test_sim_9_4_chaotic_churn_g5_regression);
|
||
RUN_TEST(test_sim_9_5_node_restart_resumes_replication);
|
||
|
||
/* simulator commit-safety invariant tests (tasks 7.1-7.4) */
|
||
RUN_TEST(test_sim_7_1_figure8_commit_refusal);
|
||
RUN_TEST(test_sim_7_2_leader_completeness_across_changes);
|
||
RUN_TEST(test_sim_7_3_freshness_gate_lexicographic);
|
||
RUN_TEST(test_sim_7_4_on_commit_advanced_trigger_semantics);
|
||
|
||
/* conformance init + in-memory adapter callbacks (task 1.2) */
|
||
RUN_TEST(test_sim_conformance_init_append_readback);
|
||
|
||
/* conformance bag push/deliver routing (task 2.1) */
|
||
RUN_TEST(test_sim_conformance_bag_deliver_routes_rpcs);
|
||
|
||
/* bag-only Drop/Duplicate/Reorder mutate only the bag (task 2.2) */
|
||
RUN_TEST(test_sim_2_2_drop_duplicate_preserve_node_state);
|
||
RUN_TEST(test_sim_2_2_reorder_prng_deterministic);
|
||
|
||
/* Group A: basic election correctness (task 5.1) */
|
||
RUN_TEST(test_proto_A1_single_node_self_elects);
|
||
RUN_TEST(test_proto_A2_3node_one_leader);
|
||
RUN_TEST(test_proto_A3_late_election_denied);
|
||
RUN_TEST(test_proto_A4_5node_quorum_3);
|
||
RUN_TEST(test_proto_A5_leader_denies_vote_request);
|
||
|
||
/* Group B: voted_for invariant (task 5.1) */
|
||
RUN_TEST(test_proto_B1_one_vote_per_term);
|
||
RUN_TEST(test_proto_B2_higher_term_updates_state);
|
||
RUN_TEST(test_proto_B3_stale_term_denied);
|
||
RUN_TEST(test_proto_B4_different_candidate_denied);
|
||
RUN_TEST(test_proto_B5_idempotent_same_candidate);
|
||
|
||
/* Group C: on_vote_eligible policy callback (task 5.2) */
|
||
RUN_TEST(test_proto_C1_eligible_denies);
|
||
RUN_TEST(test_proto_C2_eligible_grants);
|
||
RUN_TEST(test_proto_C3_eligible_null_grants);
|
||
RUN_TEST(test_proto_C4_stale_term_no_eligible_call);
|
||
RUN_TEST(test_proto_C5_already_voted_no_eligible_call);
|
||
|
||
/* Group H: Persistence and restart (task 5.2) */
|
||
RUN_TEST(test_proto_H1_init_with_persisted_state);
|
||
RUN_TEST(test_proto_H2_init_first_boot);
|
||
RUN_TEST(test_proto_H3_persist_before_vote_recorded);
|
||
RUN_TEST(test_proto_H4_persist_fail_denies);
|
||
RUN_TEST(test_proto_H5_start_election_self_vote_persisted);
|
||
RUN_TEST(test_proto_H6_restored_vote_blocks_second_vote);
|
||
RUN_TEST(test_proto_Hnull_required_callback_einval);
|
||
|
||
/* Group D: Quorum and Result (task 5.3) */
|
||
RUN_TEST(test_proto_D1_quorum_1grant_of_2_needed);
|
||
RUN_TEST(test_proto_D2_both_peers_deny_becomes_follower);
|
||
RUN_TEST(test_proto_D3_second_vote_reply_noop);
|
||
RUN_TEST(test_proto_D4_reply_higher_term_steps_down);
|
||
|
||
/* Group E: Election Timeout (task 5.3) */
|
||
RUN_TEST(test_proto_E1_timeout_before_quorum);
|
||
RUN_TEST(test_proto_E2_timeout_after_quorum_noop);
|
||
RUN_TEST(test_proto_E3_new_election_after_timeout);
|
||
|
||
/* Group F: RDHUP Failover (task 5.4) */
|
||
RUN_TEST(test_proto_F1_leader_lost_priority_delay);
|
||
RUN_TEST(test_proto_F2_voted_during_delay_skip);
|
||
RUN_TEST(test_proto_F3_no_vote_during_delay_starts_election);
|
||
RUN_TEST(test_proto_F4_simultaneous_rdhup_one_leader);
|
||
|
||
/* Group G: Split-Brain Prevention (task 5.4) */
|
||
RUN_TEST(test_proto_G1_no_split_brain_100_rounds);
|
||
RUN_TEST(test_proto_G2_partition_minority_no_quorum);
|
||
#endif
|
||
}
|