feat(ha-term-safety): route AppendEntries intake through library + leaderCommit wire (task 3.2)

This commit is contained in:
Rongsong Shen 2026-06-22 08:42:35 +00:00
parent 6a519a928b
commit 754e586f8f
10 changed files with 508 additions and 168 deletions

View file

@ -1102,6 +1102,180 @@ static void test_primary_log_append_non_leader_legacy_fallback(void)
printf("test_primary_log_append_non_leader_legacy_fallback: PASS\n");
}
/* ================================================================== */
/* Task 3.2 — route heartbeat/replication intake through the library */
/* raft_on_append_entries_request: UpdateTerm (G5) + logOk (G2) + */
/* (term,lsn) conflict-truncate (G3) via the wired callbacks. */
/* */
/* These drive the production intake helper ha_consume_append_entries */
/* (the in-process route that replaces the term-blind handling in */
/* ha_consume_heartbeat_committed + the committed-boundary truncate in */
/* ha_rejoin_reconcile). RED: that helper does not exist pre-3.2 */
/* (compile RED); once it exists the three observable behaviors hold. */
/* ================================================================== */
/* Make a node a STANDBY follower at a given term + raft log floor, with the
* raft library initialized so the intake can drive it. Seeds the library log
* to match the persisted WAL tip (the ha_init_raft floor restore). */
static void make_follower(cluster_t* c, uint64_t term, int n_peers)
{
atomic_store(&c->ha.role, CTRL_STANDBY);
atomic_store(&c->ha.term, term);
ha_init_raft(&c->ha, c, n_peers);
c->ha.raft.term = term;
c->ha.raft.role = RAFT_FOLLOWER;
}
/* Build a decoded single-entry WalCatchupEntry (n_entries==0 path == one entry
* carried in the top-level container, matching the on-wire single-entry mode).
* For a heartbeat use n_entries set so the helper sees zero entries. */
static void fill_ae_entry(wal_catchup_entry_t* e, int is_heartbeat,
uint64_t prev_log_index, uint64_t prev_log_term,
uint64_t leader_commit, uint64_t entry_lsn,
uint8_t entry_type, uint64_t entry_term,
uint8_t* payload, uint32_t plen)
{
memset(e, 0, sizeof(*e));
e->prev_log_index = prev_log_index;
e->prev_log_term = prev_log_term;
e->leader_commit = leader_commit;
if (is_heartbeat) {
e->n_entries = 0;
e->lsn = 0;
} else {
/* single-entry mode: top-level container carries the entry, n_entries==1
* with no nested sub-entries (one-entry AppendEntries). */
e->n_entries = 1;
e->lsn = entry_lsn;
e->entry_type = entry_type;
e->term = entry_term;
e->raw_payload = payload;
e->raw_payload_len = plen;
}
}
/* (a) A higher-term heartbeat (0-entry AppendEntries) routed through the library
* makes the follower ADOPT the higher term and step down — term recorded
* (R2.6 / G5). Pre-3.2 ha_consume_heartbeat_committed only DROPPED stale
* heartbeats and never adopted a higher term. */
static void test_intake_higher_term_heartbeat_adopts_term(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_ts_intake_ht_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
cluster_t* c = make_cluster(tmp);
TEST_ASSERT_NOT_NULL(c);
make_follower(c, /*term*/3, /*n_peers*/3);
/* The follower briefly believes itself a candidate/leader at term 3. */
c->ha.raft.role = RAFT_LEADER;
/* Heartbeat from a leader at term 7, empty log (prev=0), commit 0. The
* heartbeat's top-level container carries the leader's live term. */
wal_catchup_entry_t hb;
fill_ae_entry(&hb, /*heartbeat*/1, /*prev_idx*/0, /*prev_term*/0,
/*leader_commit*/0, 0, 0, 0, NULL, 0);
hb.term = 7;
int rc = ha_consume_append_entries(c, &hb, /*leader_idx*/1);
TEST_ASSERT_EQUAL_INT(0, rc);
/* The library adopted the higher term and stepped down to follower. */
TEST_ASSERT_EQUAL_UINT64(7, c->ha.raft.term);
TEST_ASSERT_NOT_EQUAL_INT(RAFT_LEADER, c->ha.raft.role);
teardown_cluster(c);
printf("test_intake_higher_term_heartbeat_adopts_term: PASS\n");
}
/* (b) A divergent same-LSN tail is truncated and the leader's entry accepted
* (R3.1/R3.2 / G3), and the committed prefix below the divergence is untouched
* (R3.3). The follower locally holds [LSN1@term2, LSN2@term2]; the leader sends
* an AppendEntries appending LSN2@term5 on top of LSN1@term2. The library
* detects the (term,lsn) conflict at LSN2, truncates LSN>=2, and re-persists
* the leader's LSN2@term5. LSN1 (the agreed prefix) stays intact. */
static void test_intake_divergent_tail_truncated_and_reconverged(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_ts_intake_div_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
cluster_t* c = make_cluster(tmp);
TEST_ASSERT_NOT_NULL(c);
make_follower(c, /*term*/2, /*n_peers*/3);
/* Seed the local log: LSN1@term2, LSN2@term2 (the divergent tail). */
uint8_t blob[64];
size_t n = pack_entry(blob, WAL_JOB_UPDATE, "a", 1);
TEST_ASSERT_EQUAL_INT(0, ha_cb_persist_entry_for_test(&c->ha, 1, 2, blob, n));
TEST_ASSERT_EQUAL_INT(0, ha_cb_persist_entry_for_test(&c->ha, 2, 2, blob, n));
c->ha.raft.log_len = 2;
TEST_ASSERT_EQUAL_UINT64(2, persist_entry_term(&c->persist, 1));
TEST_ASSERT_EQUAL_UINT64(2, persist_entry_term(&c->persist, 2));
/* Leader (term 5) sends AppendEntries: prev=(LSN1,term2), entry LSN2@term5.
* logOk holds at LSN1 (term2==term2); conflict at LSN2 (local term2 !=
* incoming term5) → truncate LSN>=2 then persist LSN2@term5. */
uint8_t lpay[1] = { 'b' };
wal_catchup_entry_t ae;
fill_ae_entry(&ae, /*heartbeat*/0, /*prev_idx*/1, /*prev_term*/2,
/*leader_commit*/1, /*entry_lsn*/2, WAL_JOB_UPDATE,
/*entry_term*/5, lpay, 1);
int rc = ha_consume_append_entries(c, &ae, /*leader_idx*/1);
TEST_ASSERT_EQUAL_INT(0, rc);
/* (term,lsn) reconverged: LSN2 now carries the leader's term 5. */
TEST_ASSERT_EQUAL_UINT64(5, persist_entry_term(&c->persist, 2));
/* The agreed committed prefix (LSN1@term2) is untouched (R3.3). */
TEST_ASSERT_EQUAL_UINT64(2, persist_entry_term(&c->persist, 1));
/* The higher leader term was adopted on the RPC (G5). */
TEST_ASSERT_EQUAL_UINT64(5, c->ha.raft.term);
teardown_cluster(c);
printf("test_intake_divergent_tail_truncated_and_reconverged: PASS\n");
}
/* (c) An unreconcilable member (logOk fails: the leader's prev tuple does not
* match the follower's log) is REJECTED — the follower neither persists the
* leader's entry nor reports false convergence, and stays available to retry
* (no crash, no committed advance). R3.5/R3.6: the member keeps its divergent
* state pending a later catch-up rather than spuriously accepting. */
static void test_intake_logmismatch_rejected_no_false_convergence(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_ts_intake_rej_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
cluster_t* c = make_cluster(tmp);
TEST_ASSERT_NOT_NULL(c);
make_follower(c, /*term*/4, /*n_peers*/3);
/* Local log: LSN1@term2. The leader claims prev=(LSN1,term3) — a term
* mismatch at LSN1 → logOk fails. */
uint8_t blob[64];
size_t n = pack_entry(blob, WAL_JOB_UPDATE, "a", 1);
TEST_ASSERT_EQUAL_INT(0, ha_cb_persist_entry_for_test(&c->ha, 1, 2, blob, n));
c->ha.raft.log_len = 1;
uint8_t lpay[1] = { 'z' };
wal_catchup_entry_t ae;
fill_ae_entry(&ae, /*heartbeat*/0, /*prev_idx*/1, /*prev_term*/3,
/*leader_commit*/0, /*entry_lsn*/2, WAL_JOB_UPDATE,
/*entry_term*/4, lpay, 1);
int rc = ha_consume_append_entries(c, &ae, /*leader_idx*/1);
/* The helper returns 0 (no crash) but the AE was NOT accepted: the leader's
* entry was not persisted at LSN2 and the local LSN1 entry is unchanged. */
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(0, persist_entry_term(&c->persist, 2)); /* absent */
TEST_ASSERT_EQUAL_UINT64(2, persist_entry_term(&c->persist, 1)); /* intact */
teardown_cluster(c);
printf("test_intake_logmismatch_rejected_no_false_convergence: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
@ -1131,4 +1305,7 @@ void test_term_safety_adapter(void)
RUN_TEST(test_primary_log_append_stamps_term_and_assigns_lsn);
RUN_TEST(test_own_term_noop_records_live_term_via_library);
RUN_TEST(test_primary_log_append_non_leader_legacy_fallback);
RUN_TEST(test_intake_higher_term_heartbeat_adopts_term);
RUN_TEST(test_intake_divergent_tail_truncated_and_reconverged);
RUN_TEST(test_intake_logmismatch_rejected_no_false_convergence);
}