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:
Rongsong Shen 2026-06-22 09:25:44 +00:00
parent 754e586f8f
commit f1783f7952
4 changed files with 466 additions and 2 deletions

View file

@ -2894,6 +2894,86 @@ int ha_primary_log_append(cluster_t *c, uint32_t type,
return rc;
}
/* ha_repl_ack_abs_peer_idx (ha-term-safety task 3.3 remediation): resolve the
* ABSOLUTE library peer index from an ACK endpoint's stored peer_idx, branching
* on endpoint type EXACTLY as the standby_endpoints scan at ha.c:6187-6189.
*
* Two endpoint types reach the walAck branch of repl_ack_decode_and_dispatch,
* and their stored peer_idx has DIFFERENT meaning:
* - Promoted vote ep (vote_ep_ctx_t, ep_is_vote != 0): peer_idx is a
* self-EXCLUDED "votes" index (relative). Map it back to the absolute index
* — the inverse of raft_cb_send_vote_request's votes_idx remap (self_idx ==
* my_priority): abs = votes_idx < self_idx ? votes_idx : votes_idx + 1.
* - Heartbeat standby ep (standby_ep_ctx_t, ctx==NULL, ep_is_vote == 0):
* peer_idx is ALREADY the absolute candidate index — pass it through.
*
* The earlier unconditional remap corrupted the heartbeat path: a standby ep's
* already-absolute index was remapped a second time, crediting the WRONG peer
* (false quorum / stuck commit) or, near the array bound, an OUT-OF-BOUNDS
* match_index[] write. This resolver is the single source of truth so the
* production call site and the unit test exercise identical logic. */
int ha_repl_ack_abs_peer_idx(ha_state_t *ha, int ep_is_vote, int peer_idx)
{
int self_idx = ha->my_priority; /* self_idx == my_priority by convention */
if (ep_is_vote)
return (peer_idx < self_idx) ? peer_idx : peer_idx + 1;
return peer_idx; /* heartbeat standby ep: already absolute */
}
/* ha-term-safety task 3.3 (R4.1/R4.5): drive the raft library on a replication
* ACK. Route the reply through raft_on_append_entries_reply (updating
* match_index[]/next_index[]) then raft_advance_commit, so the library
* recomputes the current-term-gated commit (Figure-8) and MAY fire
* on_commit_advanced — the task-2.4 callback feeding the synchronous barrier.
*
* Peer-index contract: `abs_peer_idx` is the ABSOLUTE library peer index
* (j != self_idx), the same index space the library uses for
* match_index[]/next_index[]. The CALLER resolves it per endpoint type via
* ha_repl_ack_abs_peer_idx (promoted-vote eps carry a relative votes index;
* heartbeat standby eps carry an already-absolute index) — this seam must NOT
* remap again or it would corrupt the heartbeat path.
*
* Defense-in-depth: a peer index outside [0, raft.n_peers) (and never self) is
* rejected loudly rather than passed into raft_on_append_entries_reply, whose
* match_index[] write is unchecked. This fences an OOB / wrong-peer write at the
* controller boundary (no assert inside src/lib/raft/) and self-heals — the
* caller simply skips this ACK's library accounting.
*
* The library drops a stale-term reply (reply_term != s->term) inside
* raft_on_append_entries_reply, so a stale ACK never touches match_index[] and
* never advances commit. raft_advance_commit is leader-only (-EPERM otherwise);
* on a non-leader the call is a harmless no-op. Neither library call performs
* I/O — safe on the iomgr ACK path. */
int ha_raft_on_repl_ack(cluster_t *c, int abs_peer_idx, uint64_t acked_lsn,
uint64_t reply_term)
{
if (!c)
return -EINVAL;
/* Defense-in-depth bounds/self check: the library's match_index[] write is
* unchecked, so reject a bogus index here (fail loud, never corrupt). */
int self_idx = c->ha.my_priority;
int n_peers = c->ha.raft.n_peers;
if (abs_peer_idx < 0 || abs_peer_idx >= n_peers || abs_peer_idx == self_idx) {
ERROR("ha_raft_on_repl_ack: rejecting out-of-range peer index %d "
"(n_peers=%d self_idx=%d) — skipping library ACK accounting",
abs_peer_idx, n_peers, self_idx);
return -EINVAL;
}
/* A successful WalAck reports the standby's matched index (acked_lsn). The
* library applies it only when reply_term == s->term (stale-term drop). */
(void)raft_on_append_entries_reply(&c->ha.raft, abs_peer_idx, /*success*/1,
acked_lsn, reply_term);
/* Recompute the current-term-gated commit; fires on_commit_advanced exactly
* once on a strict advance of a current-term entry (else holds). Leader-only:
* -EPERM off the leader leaves commit_index unchanged with no callback. */
(void)raft_advance_commit(&c->ha.raft);
return 0;
}
/*
* ha_arm_own_term_gate: append the own-term WAL_NOOP and arm the commit
* engine's writable_lsn gate (ha-single-leader-role task 2.2, Req 1.4/2.1/2.2).
@ -4217,7 +4297,8 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
struct WalAckReply war_raw;
memset(&war_raw, 0, sizeof(war_raw));
read_WalAckReply(&war_raw, rep_s.data.walackreplymsg);
uint64_t acked_lsn = (uint64_t)war_raw.lsn;
uint64_t acked_lsn = (uint64_t)war_raw.lsn;
uint64_t reply_term = (uint64_t)war_raw.term;
int success = war_raw.success ? 1 : 0;
capn_free(&capn_ctx);
@ -4225,6 +4306,25 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
repl_on_ack(&c->persist.repl, ep, acked_lsn);
TRACE("repl_ack_decode_and_dispatch: WAL ACK from standby"
" lsn=%lu success=1", (unsigned long)acked_lsn);
/* ha-term-safety task 3.3 (R4.1/R4.5): route the ACK through the
* raft library so it updates match_index[]/next_index[] and
* recomputes the current-term-gated commit (raft_advance_commit).
* This fires on_commit_advanced (task 2.4) ONLY when a quorum
* agrees on a CURRENT-TERM entry — making the library the live
* commit source alongside the durable barrier; a stale-term reply
* is dropped inside the library. Driven on the same iomgr ACK
* path; neither library call performs I/O.
*
* Resolve the ABSOLUTE library peer index per endpoint type FIRST
* (remediation): this branch fires for BOTH promoted-vote eps
* (vctx->ctx != NULL, peer_idx is a relative votes index) AND
* heartbeat standby eps (vctx->ctx == NULL, peer_idx is already
* absolute) — see the authoritative scan at ha.c:6187-6189. A
* blind remap would double-map the heartbeat path onto the wrong
* (or out-of-range) match_index[] slot. */
int ack_abs_idx = ha_repl_ack_abs_peer_idx(
&c->ha, /*ep_is_vote*/ vctx->ctx != NULL, vctx->peer_idx);
ha_raft_on_repl_ack(c, ack_abs_idx, acked_lsn, reply_term);
/* ha-convergence-recovery task 4.2 (D6, Req 5.1/5.2/7.1):
* ACK-driven catch-up — continue streaming to a standby still
* behind target, or re-include it at parity. Shares the exact

View file

@ -333,6 +333,47 @@ int ha_self_restart(cluster_t *c);
int ha_primary_log_append(cluster_t *c, uint32_t type,
const void *payload, uint32_t len, uint64_t *out_lsn);
/*
* ha_raft_on_repl_ack (ha-term-safety task 3.3, R4.1/R4.5): drive the library on
* a replication ACK. Routes a successful WalAck through raft_on_append_entries_reply
* (updating the library's match_index[]/next_index[] at the absolute peer index)
* then raft_advance_commit, so the library recomputes the current-term-gated
* commit (the Figure-8 rule) and MAY fire on_commit_advanced — the task-2.4
* callback that feeds the existing synchronous commit barrier. A current-term
* quorum advances committed only when the agreed entry is current-term; a
* prior-term agreed entry is held; a stale-term reply is dropped by the library.
*
* `abs_peer_idx` is the ABSOLUTE library peer index (j != self_idx), the same
* index space as match_index[]/next_index[]. The CALLER resolves it per endpoint
* type via ha_repl_ack_abs_peer_idx — promoted-vote eps carry a relative votes
* index, heartbeat standby eps carry an already-absolute index. This seam must
* NOT remap again. An out-of-range / self index is rejected loudly (returns
* -EINVAL, no library accounting) so the library's unchecked match_index[] write
* is never corrupted (no false quorum, no OOB). `acked_lsn` is the standby's
* matched index (WalAckReply.lsn); `reply_term` is the reply's term.
*
* Leader-only effect: raft_advance_commit returns -EPERM off the leader (no
* commit change, no callback) — harmless on a node that is no longer primary.
* Non-blocking (ha-async-io): the library performs no I/O; on_commit_advanced
* only records the barrier ceiling. Returns 0 on success; -EINVAL on a
* no-cluster context or a rejected peer index. */
int ha_raft_on_repl_ack(cluster_t *c, int abs_peer_idx, uint64_t acked_lsn,
uint64_t reply_term);
/*
* ha_repl_ack_abs_peer_idx (ha-term-safety task 3.3 remediation): resolve the
* ABSOLUTE library peer index from an ACK endpoint's stored peer_idx, branching
* on endpoint type (mirrors the standby_endpoints scan at ha.c:6187-6189):
* - ep_is_vote != 0 (promoted vote ep, vote_ep_ctx_t): peer_idx is a
* self-EXCLUDED votes index → remap abs = peer_idx < self_idx ? peer_idx
* : peer_idx+1.
* - ep_is_vote == 0 (heartbeat standby ep, standby_ep_ctx_t, ctx==NULL):
* peer_idx is ALREADY absolute → pass through.
* self_idx == ha->my_priority. Pure (no side effects); the single source of
* truth shared by the production ACK call site and the unit test so the
* remap-vs-passthrough distinction cannot drift. */
int ha_repl_ack_abs_peer_idx(ha_state_t *ha, int ep_is_vote, int peer_idx);
/*
* ha_schedule_demotion_convergence: schedule the one-shot delaymgr task that
* truncates above committed_lsn (Req 1.2), WARN-logs the discarded range

View file

@ -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);
}