feat(ha-prevote): route preVoteReply over real transport to the candidate tally (task 5.1)

peer_ctrl_msg_handler gains a CTRL_STANDBY + pre_vote_in_progress branch that
decodes an incoming preVoteReply (no term adoption) and feeds it to
pre_vote_reply_dispatch with the absolute peer index, paralleling the real-vote
reply path. Gives pre_vote_reply_dispatch its production caller (drops the used
attribute). STANDBY-no-round still drops; ELECTING/PRIMARY routing unchanged.
PreVote now drives rounds to a decision end-to-end over the wire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-06-16 06:47:08 +00:00
parent b765fc4705
commit 6d22f2eb1a
3 changed files with 193 additions and 5 deletions

View file

@ -63,6 +63,33 @@ extern void ha_prevote_round_timeout_fire_for_test(ha_state_t *ha, cluster_t *c)
* LOST outcome without a live delaymgr. */
extern int (*ha_prevote_rearm_liveness_hook)(ha_state_t *ha, cluster_t *c);
/* ── Task 5.1 transport-route seams ──────────────────────────────────────── */
/*
* pre_vote_reply_msg_handler_for_test: drive a preVoteReply through the REAL
* peer_ctrl_msg_handler decode/route path while CTRL_STANDBY + round in
* progress (the production condition). Proves the reply is NOT dropped and
* reaches pre_vote_reply_dispatch — end-to-end, not via the test hook.
*/
extern int pre_vote_reply_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt);
/* vote_reply_build_pkt_for_test: encode a RequestVoteReply capnp packet (the
* preVoteReply reuses the identical payload / union arm). */
extern io_packet_t *vote_reply_build_pkt_for_test(uint64_t term, int vote_granted,
uint64_t peer_tag,
uint64_t peer_lsn);
/* vote_ep_ctx_build_for_test: allocate a vote_ep_ctx_t and wire ep->data with
* the ABSOLUTE peer_idx (the index pre_vote_reply_dispatch expects). */
extern endpoint_t *vote_ep_ctx_build_for_test(endpoint_t *ep,
ha_state_t *ha,
ha_elect_ctx_t *ctx,
int peer_idx,
uint64_t my_term,
uint64_t my_tag,
uint64_t my_lsn,
const char *my_name);
/* ── Test config ─────────────────────────────────────────────────────────── */
static ha_config_t g_cfg = {
@ -323,6 +350,61 @@ static void test_prevote_pending_is_noop(void)
printf("test_prevote_pending_is_noop: PASS\n");
}
/* ── Test 7: a preVoteReply over the REAL transport reaches the tally (5.1) ─ */
/*
* Task 5.1 (Req 7.1): a preVoteReply arriving on the candidate's outbound
* PreVote endpoint — while the candidate is still CTRL_STANDBY with a round in
* progress — must be ROUTED to the PreVote tally by peer_ctrl_msg_handler (NOT
* dropped because the role is STANDBY), and drive the round to a decision
* end-to-end. This exercises the production decode/route path, not the
* pre_vote_reply_dispatch_for_test hook.
*/
static void test_prevote_reply_over_real_transport_reaches_tally(void)
{
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 0));
ha_do_demote(&ha); /* role = CTRL_STANDBY */
make_three_node(&ha);
/* Start a real round: self-grant + in-progress, role stays CTRL_STANDBY. */
ha_start_prevote_async(&ha, NULL);
TEST_ASSERT_EQUAL_INT_MESSAGE(CTRL_STANDBY,
(int)atomic_load_explicit(&ha.role, memory_order_acquire),
"precondition: role must be CTRL_STANDBY during the round");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha.raft.pre_vote_in_progress,
"precondition: round must be in progress");
/* Build an endpoint carrying ABSOLUTE peer_idx = 1 (the index the tally
* dedups by), exactly as ha_send_pre_vote_request sets it. */
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
vote_ep_ctx_build_for_test(&ep, &ha, NULL, /*peer_idx=*/1,
/*my_term=*/1, /*my_tag=*/0, /*my_lsn=*/0, "test");
/* Encode a GRANT preVoteReply (same wire payload as a real-vote reply). */
io_packet_t *pkt = vote_reply_build_pkt_for_test(/*term=*/1, /*granted=*/1,
/*tag=*/0, /*lsn=*/0);
TEST_ASSERT_NOT_NULL_MESSAGE(pkt, "vote_reply_build_pkt_for_test must succeed");
/* Route it through the REAL handler (peer_ctrl_msg_handler) while STANDBY. */
int rc = pre_vote_reply_msg_handler_for_test(&ep, pkt);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "handler must return 0");
/* Peer grant → quorum (2 of 3) → WON: the reply was NOT dropped; it reached
* the tally and drove the round to a decision (real-election sequence). */
int role = (int)atomic_load_explicit(&ha.role, memory_order_acquire);
TEST_ASSERT_TRUE_MESSAGE(role == CTRL_ELECTING || role == CTRL_PRIMARY,
"Req 7.1: a preVoteReply over the real transport must reach the tally "
"and drive the round to a decision (left CTRL_STANDBY)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, ha.raft.pre_vote_in_progress,
"the decided round must clear the in-progress flag");
free(ep.data); /* vctx allocated by vote_ep_ctx_build_for_test */
ha_fini(&ha);
printf("test_prevote_reply_over_real_transport_reaches_tally: PASS (role=%d)\n", role);
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_ha_prevote(void)
@ -334,5 +416,6 @@ void test_ha_prevote(void)
RUN_TEST(test_prevote_round_timeout_is_loss);
RUN_TEST(test_prevote_reusable_after_loss);
RUN_TEST(test_prevote_pending_is_noop);
RUN_TEST(test_prevote_reply_over_real_transport_reaches_tally);
UNITY_END();
}