feat(ha-term-safety): drive ACK path through raft_on_append_entries_reply + advance_commit (task 3.3)
Includes fix for bug-960: endpoint-type-aware peer-index resolution (promoted-vote relative vs heartbeat-standby absolute) with fail-loud bounds check, preventing an out-of-bounds match_index[] write / wrong-peer false-quorum on the replication ACK path.
This commit is contained in:
parent
754e586f8f
commit
f1783f7952
4 changed files with 466 additions and 2 deletions
|
|
@ -1,4 +1,5 @@
|
|||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
|
@ -1276,6 +1277,317 @@ static void test_intake_logmismatch_rejected_no_false_convergence(void)
|
|||
printf("test_intake_logmismatch_rejected_no_false_convergence: PASS\n");
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Task 3.3 — drive raft_on_append_entries_reply + raft_advance_commit */
|
||||
/* on the replication ACK path */
|
||||
/* ================================================================== */
|
||||
|
||||
/* These tests drive the ACK seam ha_raft_on_repl_ack(c, abs_peer_idx, acked_lsn,
|
||||
* reply_term): on a replication ACK the controller routes the reply through
|
||||
* raft_on_append_entries_reply (updating match_index[]/next_index[]) then
|
||||
* raft_advance_commit, which recomputes the current-term-gated commit and MAY
|
||||
* fire on_commit_advanced (the task-2.4 callback that feeds the barrier).
|
||||
*
|
||||
* Peer-index contract (REMEDIATION round 1): ha_raft_on_repl_ack now takes the
|
||||
* ABSOLUTE library peer index directly (the match_index[]/next_index[] index
|
||||
* space, j != self_idx). The CALLER resolves it per endpoint type via
|
||||
* ha_repl_ack_abs_peer_idx BEFORE this seam — promoted-vote eps carry a relative
|
||||
* "votes" index (remapped), heartbeat standby eps carry an already-absolute
|
||||
* index (passed through). The earlier blind remap inside the seam corrupted the
|
||||
* heartbeat path (wrong-peer false quorum / OOB match_index[] write); the
|
||||
* endpoint-type round-trip is pinned by the two remediation tests below.
|
||||
*
|
||||
* Fixture: make_leader(c, term, n_peers) brings the node up as the raft LEADER
|
||||
* with self_idx == my_priority == 0 (make_cluster calloc's my_priority=0), so
|
||||
* the only standby's absolute peer index is 1.
|
||||
*
|
||||
* Against the pre-3.3 ACK path (repl_on_ack durability only, no library reply),
|
||||
* ha_raft_on_repl_ack does not exist (compile RED); once wired, a current-term
|
||||
* quorum ACK MUST advance the library commit_index and fire on_commit_advanced
|
||||
* (R4.1/R4.5), a prior-term agreed entry MUST hold, and a stale-term reply MUST
|
||||
* be dropped. */
|
||||
|
||||
/* Seed `count` entries at the given term into the leader's local WAL via the
|
||||
* wired on_persist_entry, growing the library log_len in lockstep. */
|
||||
static void leader_seed_entries(cluster_t* c, uint64_t term, int count)
|
||||
{
|
||||
uint8_t blob[64];
|
||||
for (int i = 1; i <= count; i++) {
|
||||
size_t n = pack_entry(blob, WAL_JOB_UPDATE, "x", 1);
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
ha_cb_persist_entry_for_test(&c->ha, (uint64_t)i, term, blob, n));
|
||||
c->ha.raft.log_len = (uint64_t)i;
|
||||
}
|
||||
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c->persist));
|
||||
}
|
||||
|
||||
/* (a) A current-term quorum ACK drives raft_on_append_entries_reply →
|
||||
* raft_advance_commit → on_commit_advanced fires with the agreed commit_index,
|
||||
* which raises the barrier ceiling. 3-peer cluster (self + 2 standbys, quorum=2):
|
||||
* self holds LSN 1,2 at the current term; one standby ACKs LSN 2 → {self, peer1}
|
||||
* is a quorum for LSN 2, the agreed entry is current-term, so commit advances to
|
||||
* 2 and the library ceiling (commit_set_raft_commit_index effect) caps committed
|
||||
* at the library point under a durable majority. */
|
||||
static void test_repl_ack_currentterm_quorum_advances_commit(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_ts_ack_adv_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
const uint64_t T = 6;
|
||||
|
||||
cluster_t* c = make_cluster(tmp);
|
||||
TEST_ASSERT_NOT_NULL(c);
|
||||
make_leader(c, T, /*n_peers*/3);
|
||||
|
||||
/* Init the cluster's commit engine over the same 3-peer cluster so a
|
||||
* majority is self + 1 standby, and register one standby whose durable ACK
|
||||
* supplies the durable-majority floor. */
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
commit_engine_init(&c->commit, &c->persist, /*n_peers*/3, /*n_vcpus*/8));
|
||||
|
||||
/* Seed LSN 1,2 at the current term T → durable floor reaches LSN 2. */
|
||||
leader_seed_entries(c, T, /*count*/2);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
repl_add_standby(&c->persist.repl, &g_ae_dummy_ep, "sb0", 7000));
|
||||
/* Durable majority: the standby's durable position covers LSN 2 so the
|
||||
* barrier ceiling is not held below the library commit point. */
|
||||
atomic_store(&c->persist.repl.standbys[0].acked_lsn, 2);
|
||||
commit_on_ack(&c->commit, /*peer_idx*/0, /*acked_lsn*/2);
|
||||
|
||||
/* Nothing committed yet: the library has not advanced commit_index (no ACK
|
||||
* routed through the library) so the commit source is still inert at 0. */
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.commit_index);
|
||||
|
||||
/* The standby ACKs LSN 2 at the leader's current term. The caller has
|
||||
* already resolved the absolute peer index (self_idx 0 → the only standby is
|
||||
* absolute peer 1); the seam takes that absolute index directly. */
|
||||
int rc = ha_raft_on_repl_ack(c, /*abs_peer_idx*/1, /*acked_lsn*/2, /*reply_term*/T);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
/* (1) The library recorded the peer's match_index at the absolute index. */
|
||||
TEST_ASSERT_EQUAL_UINT64(2, c->ha.raft.match_index[1]);
|
||||
TEST_ASSERT_EQUAL_UINT64(3, c->ha.raft.next_index[1]);
|
||||
|
||||
/* (2) raft_advance_commit advanced the current-term-gated commit_index to 2
|
||||
* ({self, peer1} quorum, entry at LSN 2 is term T). */
|
||||
TEST_ASSERT_EQUAL_UINT64(2, c->ha.raft.commit_index);
|
||||
|
||||
/* (3) on_commit_advanced fired → the barrier ceiling rose and committed_lsn
|
||||
* advanced to the library point under the durable majority (R4.1). */
|
||||
TEST_ASSERT_EQUAL_UINT64(2, atomic_load(&c->persist.committed_lsn));
|
||||
|
||||
commit_engine_fini(&c->commit);
|
||||
teardown_cluster(c);
|
||||
printf("test_repl_ack_currentterm_quorum_advances_commit: PASS\n");
|
||||
}
|
||||
|
||||
/* (b) A quorum that agrees only on a PRIOR-term entry does NOT advance commit
|
||||
* (the Figure-8 current-term rule, R4.1/R4.5). self holds LSN 1@term3 (a
|
||||
* prior-term entry) and is now leader at term 6 with no current-term entry yet.
|
||||
* A standby ACKs LSN 1 → {self, peer1} is a quorum for LSN 1, but the entry at
|
||||
* LSN 1 is term 3 != current term 6, so raft_advance_commit holds commit_index
|
||||
* at 0 and on_commit_advanced never fires. */
|
||||
static void test_repl_ack_priorterm_quorum_holds_commit(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_ts_ack_prior_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
const uint64_t PRIOR = 3;
|
||||
const uint64_t CUR = 6;
|
||||
|
||||
cluster_t* c = make_cluster(tmp);
|
||||
TEST_ASSERT_NOT_NULL(c);
|
||||
make_leader(c, CUR, /*n_peers*/3);
|
||||
|
||||
/* Seed a single PRIOR-term entry at LSN 1 (replicated from an old leader). */
|
||||
leader_seed_entries(c, PRIOR, /*count*/1);
|
||||
|
||||
/* A standby ACKs LSN 1 at the leader's current term (the wire reply term is
|
||||
* the leader's term; the ENTRY's term is what gates the commit). */
|
||||
int rc = ha_raft_on_repl_ack(c, /*abs_peer_idx*/1, /*acked_lsn*/1, /*reply_term*/CUR);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
/* match_index updated (the reply itself is current-term, not stale). */
|
||||
TEST_ASSERT_EQUAL_UINT64(1, c->ha.raft.match_index[1]);
|
||||
|
||||
/* But commit is HELD: the agreed entry (LSN1@term3) is not current-term →
|
||||
* Figure-8 rule blocks the advance, commit_index stays 0. */
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.commit_index);
|
||||
|
||||
teardown_cluster(c);
|
||||
printf("test_repl_ack_priorterm_quorum_holds_commit: PASS\n");
|
||||
}
|
||||
|
||||
/* (c) A stale-term reply is dropped: raft_on_append_entries_reply discards a
|
||||
* reply whose term != s->term, so match_index[] is NOT updated and no commit
|
||||
* advance occurs (R4.5 / raft.tla 415). self holds LSN 1,2 at the current term;
|
||||
* a stale reply (reply_term < current term) for LSN 2 must leave match_index and
|
||||
* commit_index untouched. */
|
||||
static void test_repl_ack_staleterm_reply_dropped(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_ts_ack_stale_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
const uint64_t T = 8;
|
||||
const uint64_t STALE = 5;
|
||||
|
||||
cluster_t* c = make_cluster(tmp);
|
||||
TEST_ASSERT_NOT_NULL(c);
|
||||
make_leader(c, T, /*n_peers*/3);
|
||||
|
||||
leader_seed_entries(c, T, /*count*/2);
|
||||
|
||||
/* Stale-term ACK for LSN 2 → dropped by the library before any state change. */
|
||||
int rc = ha_raft_on_repl_ack(c, /*abs_peer_idx*/1, /*acked_lsn*/2, /*reply_term*/STALE);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
/* No match_index update (the peer's slot stays at the post-init default 0). */
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.match_index[1]);
|
||||
/* No commit advance. */
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.commit_index);
|
||||
|
||||
teardown_cluster(c);
|
||||
printf("test_repl_ack_staleterm_reply_dropped: PASS\n");
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Task 3.3 REMEDIATION round 1 — endpoint-type-correct peer mapping */
|
||||
/* ================================================================== */
|
||||
|
||||
/* (d) The bug: the ACK walAck branch fires for BOTH promoted-vote eps (relative
|
||||
* votes index) AND heartbeat standby eps (already-absolute index). The earlier
|
||||
* seam unconditionally remapped, so a heartbeat ep's absolute index was mapped a
|
||||
* SECOND time — crediting the WRONG peer (false quorum) or writing OUT OF BOUNDS.
|
||||
* ha_repl_ack_abs_peer_idx must remap ONLY the vote ep and pass the standby ep
|
||||
* through unchanged. We pin both endpoint types AND both my_priority values so
|
||||
* the remap-vs-passthrough distinction actually round-trips (the pre-fix tests
|
||||
* only used my_priority==0, which masked the bug).
|
||||
*
|
||||
* Round-trip oracle: for a vote ep, peer absolute index i is sent as votes_idx
|
||||
* (i < self ? i : i-1); ha_repl_ack_abs_peer_idx must recover exactly i. For a
|
||||
* standby ep, the stored peer_idx IS i and must pass through unchanged. */
|
||||
static void test_repl_ack_abs_peer_idx_endpoint_type_roundtrip(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_ts_ack_map_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
cluster_t* c = make_cluster(tmp);
|
||||
TEST_ASSERT_NOT_NULL(c);
|
||||
|
||||
/* --- self_idx (my_priority) == 0: every peer is after self --- */
|
||||
c->ha.my_priority = 0;
|
||||
/* Heartbeat standby ep (ctx==NULL → ep_is_vote=0): absolute index passes
|
||||
* through UNCHANGED. Absolute peer 1 must stay 1, absolute peer 2 stay 2.
|
||||
* (The pre-fix blind remap would have turned 1→2 and 2→3 — the bug.) */
|
||||
TEST_ASSERT_EQUAL_INT(1, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/0, /*peer*/1));
|
||||
TEST_ASSERT_EQUAL_INT(2, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/0, /*peer*/2));
|
||||
/* Promoted vote ep (ep_is_vote=1): votes_idx 0 → abs 1, votes_idx 1 → abs 2. */
|
||||
TEST_ASSERT_EQUAL_INT(1, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/1, /*votes*/0));
|
||||
TEST_ASSERT_EQUAL_INT(2, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/1, /*votes*/1));
|
||||
|
||||
/* --- self_idx (my_priority) == 1: a peer below self is NOT shifted --- */
|
||||
c->ha.my_priority = 1;
|
||||
/* Standby ep: absolute indices 0 and 2 (peer 1 is self) pass through. */
|
||||
TEST_ASSERT_EQUAL_INT(0, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/0, /*peer*/0));
|
||||
TEST_ASSERT_EQUAL_INT(2, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/0, /*peer*/2));
|
||||
/* Vote ep with self_idx==1: votes_idx 0 (peer absolute 0, < self) → 0;
|
||||
* votes_idx 1 (peer absolute 2, >= self) → 2. This is where remap and
|
||||
* passthrough DIVERGE — the case the my_priority==0 tests could not reach. */
|
||||
TEST_ASSERT_EQUAL_INT(0, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/1, /*votes*/0));
|
||||
TEST_ASSERT_EQUAL_INT(2, ha_repl_ack_abs_peer_idx(&c->ha, /*vote*/1, /*votes*/1));
|
||||
|
||||
teardown_cluster(c);
|
||||
printf("test_repl_ack_abs_peer_idx_endpoint_type_roundtrip: PASS\n");
|
||||
}
|
||||
|
||||
/* (e) End-to-end heartbeat-standby ACK: a standby ep carries an ALREADY-ABSOLUTE
|
||||
* peer index. Resolving it via ha_repl_ack_abs_peer_idx (ep_is_vote=0) and
|
||||
* driving ha_raft_on_repl_ack must land match_index on the CORRECT absolute slot
|
||||
* (not the +1 wrong slot the blind remap produced). 3-peer cluster, self=0; the
|
||||
* standby's absolute index is 1. A current-term quorum ACK for LSN 2 advances
|
||||
* the library commit_index to 2 — but ONLY because match_index[1] (not [2]) is
|
||||
* credited. Under the broken blind remap the credit landed on match_index[2]
|
||||
* (a phantom peer): no quorum at LSN 2, commit_index would stay 0. */
|
||||
static void test_repl_ack_heartbeat_standby_absolute_slot(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_ts_ack_hb_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
const uint64_t T = 6;
|
||||
|
||||
cluster_t* c = make_cluster(tmp);
|
||||
TEST_ASSERT_NOT_NULL(c);
|
||||
make_leader(c, T, /*n_peers*/3);
|
||||
TEST_ASSERT_EQUAL_INT(0, c->ha.my_priority); /* self_idx 0 */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
commit_engine_init(&c->commit, &c->persist, /*n_peers*/3, /*n_vcpus*/8));
|
||||
|
||||
leader_seed_entries(c, T, /*count*/2);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0,
|
||||
repl_add_standby(&c->persist.repl, &g_ae_dummy_ep, "sb0", 7000));
|
||||
atomic_store(&c->persist.repl.standbys[0].acked_lsn, 2);
|
||||
commit_on_ack(&c->commit, /*peer_idx*/0, /*acked_lsn*/2);
|
||||
|
||||
/* The standby ep stores the ABSOLUTE index 1 (standby_ep_ctx_t.peer_idx).
|
||||
* Resolve it as a heartbeat ep (ep_is_vote=0) — it must pass through as 1. */
|
||||
int abs_idx = ha_repl_ack_abs_peer_idx(&c->ha, /*ep_is_vote*/0, /*peer_idx*/1);
|
||||
TEST_ASSERT_EQUAL_INT(1, abs_idx); /* NOT 2 — the blind-remap bug slot */
|
||||
|
||||
int rc = ha_raft_on_repl_ack(c, abs_idx, /*acked_lsn*/2, /*reply_term*/T);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
/* The CORRECT absolute slot was credited; the phantom slot 2 stayed empty. */
|
||||
TEST_ASSERT_EQUAL_UINT64(2, c->ha.raft.match_index[1]);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.match_index[2]);
|
||||
|
||||
/* {self, peer1} is a real quorum for LSN 2 → current-term commit advances. */
|
||||
TEST_ASSERT_EQUAL_UINT64(2, c->ha.raft.commit_index);
|
||||
TEST_ASSERT_EQUAL_UINT64(2, atomic_load(&c->persist.committed_lsn));
|
||||
|
||||
commit_engine_fini(&c->commit);
|
||||
teardown_cluster(c);
|
||||
printf("test_repl_ack_heartbeat_standby_absolute_slot: PASS\n");
|
||||
}
|
||||
|
||||
/* (f) Defense-in-depth: an out-of-range / self absolute index is rejected loudly
|
||||
* (returns -EINVAL) and performs NO library accounting — fencing the unchecked
|
||||
* match_index[] write at the controller boundary rather than corrupting memory. */
|
||||
static void test_repl_ack_out_of_range_peer_rejected(void)
|
||||
{
|
||||
char tmp[256];
|
||||
strcpy(tmp, "/tmp/xce_ts_ack_oob_XXXXXX");
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
|
||||
|
||||
const uint64_t T = 6;
|
||||
|
||||
cluster_t* c = make_cluster(tmp);
|
||||
TEST_ASSERT_NOT_NULL(c);
|
||||
make_leader(c, T, /*n_peers*/3); /* valid peers are absolute 1,2 */
|
||||
leader_seed_entries(c, T, /*count*/2);
|
||||
|
||||
/* abs index == n_peers (3) is out of range → rejected, no state change. */
|
||||
TEST_ASSERT_EQUAL_INT(-EINVAL,
|
||||
ha_raft_on_repl_ack(c, /*abs_peer_idx*/3, /*acked_lsn*/2, /*reply_term*/T));
|
||||
/* self (0) is never a valid peer → rejected. */
|
||||
TEST_ASSERT_EQUAL_INT(-EINVAL,
|
||||
ha_raft_on_repl_ack(c, /*abs_peer_idx*/0, /*acked_lsn*/2, /*reply_term*/T));
|
||||
|
||||
/* Nothing was credited and commit did not advance. */
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.match_index[1]);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, c->ha.raft.commit_index);
|
||||
|
||||
teardown_cluster(c);
|
||||
printf("test_repl_ack_out_of_range_peer_rejected: PASS\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Suite entry point */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -1308,4 +1620,10 @@ void test_term_safety_adapter(void)
|
|||
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);
|
||||
RUN_TEST(test_repl_ack_currentterm_quorum_advances_commit);
|
||||
RUN_TEST(test_repl_ack_priorterm_quorum_holds_commit);
|
||||
RUN_TEST(test_repl_ack_staleterm_reply_dropped);
|
||||
RUN_TEST(test_repl_ack_abs_peer_idx_endpoint_type_roundtrip);
|
||||
RUN_TEST(test_repl_ack_heartbeat_standby_absolute_slot);
|
||||
RUN_TEST(test_repl_ack_out_of_range_peer_rejected);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue