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

@ -2628,6 +2628,11 @@ void handle_request_vote_reply(ha_state_t *ha,
static int peer_ctrl_msg_handler(void *ep_raw);
static int vote_reply_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx);
static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx);
/* ha-prevote task 5.1: preVoteReply decode/route; defined alongside the
* real-vote decoder, feeds the later-defined pre_vote_reply_dispatch. */
static int pre_vote_reply_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx);
static void pre_vote_reply_dispatch(ha_state_t *ha, cluster_t *c,
int peer_idx, int granted);
#ifdef XCE_TESTING
/*
@ -2860,6 +2865,79 @@ done:
return 0;
}
/*
* pre_vote_reply_decode_and_dispatch: decode a preVoteReply packet on an
* outbound PreVote endpoint and feed it to the PreVote tally (ha-prevote task
* 5.1). Mirrors vote_reply_decode_and_dispatch exactly — the PreVote reply
* reuses the identical RequestVoteReply payload / union arm — but routes to
* pre_vote_reply_dispatch (the raft-library PreVote tally) instead of the
* real-vote tally, and deliberately performs NO term adoption (a PreVote reply
* never changes our term; Req 1.2). vctx->peer_idx is the ABSOLUTE cluster
* index that raft_on_pre_vote_reply expects (set in ha_send_pre_vote_request).
* Packet ownership: takes ep->fsm_in.pkt and frees it.
*/
static int pre_vote_reply_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
{
ha_state_t *ha = vctx->ha;
cluster_t *c = vctx->c;
int idx = vctx->peer_idx; /* ABSOLUTE cluster index */
io_packet_t *pkt = ep->fsm_in.pkt;
int ok = 0;
int vr_granted = 0;
ep->fsm_in.pkt = NULL; /* take ownership (same pattern as
* vote_reply_decode_and_dispatch) */
if (!pkt || !pkt->data || pkt->sz == 0)
goto done;
{
struct capn capn_ctx;
struct Request outer_rq;
Request_ptr outer_rp;
struct RequestReply rep_s;
struct RequestVoteReply vr_raw;
int capn_ok;
capn_ok = capn_init_mem(&capn_ctx, (const uint8_t *)pkt->data,
(size_t)pkt->sz, 0);
if (capn_ok != 0)
goto done;
outer_rp.p = capn_getp(capn_root(&capn_ctx), 0, 1);
read_Request(&outer_rq, outer_rp);
if (outer_rq.body_which != Request_body_replymsg ||
!outer_rq.body.replymsg.p.data) {
capn_free(&capn_ctx);
goto done;
}
read_RequestReply(&rep_s, outer_rq.body.replymsg);
if (rep_s.data_which != RequestReply_data_requestvotereplymsg) {
capn_free(&capn_ctx);
goto done;
}
memset(&vr_raw, 0, sizeof(vr_raw));
read_RequestVoteReply(&vr_raw, rep_s.data.requestvotereplymsg);
capn_free(&capn_ctx);
vr_granted = vr_raw.voteGranted ? 1 : 0;
ok = 1;
}
done:
if (pkt) { free_packet(pkt); free(pkt); }
/* A malformed / wrong-body reply counts as a non-grant (best-effort
* delivery, per design Event Contract). No term adoption either way. */
pre_vote_reply_dispatch(ha, c, idx, ok ? vr_granted : 0);
return 0;
}
/*
* repl_ack_decode_and_dispatch: decode an inbound packet on a promoted vote
* endpoint while role == CTRL_PRIMARY.
@ -3048,7 +3126,15 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
* Routes by ha->role at dispatch time — no explicit handler reassignment needed.
* CTRL_ELECTING → vote_reply_decode_and_dispatch
* CTRL_PRIMARY → repl_ack_decode_and_dispatch
* CTRL_STANDBY + PreVote → pre_vote_reply_decode_and_dispatch (task 5.1)
* any other role → drop packet (WARN)
*
* PreVote note (ha-prevote task 5.1): a preVoteReply arrives on the candidate's
* OUTBOUND PreVote endpoint while the candidate's role is still CTRL_STANDBY
* (a PreVote round performs no role change — Req 1.1). Without this branch the
* reply would hit the default case and be dropped, stranding the round until it
* times out. We gate on raft.pre_vote_in_progress so a STANDBY node with no
* active round still drops unexpected traffic.
*/
static int peer_ctrl_msg_handler(void *ep_raw)
{
@ -3058,6 +3144,11 @@ static int peer_ctrl_msg_handler(void *ep_raw)
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&ha->role,
memory_order_acquire);
/* PreVote replies land while CTRL_STANDBY with a round in progress. */
if (role == CTRL_STANDBY && ha->raft.pre_vote_in_progress)
return pre_vote_reply_decode_and_dispatch(ep, vctx);
switch (role) {
case CTRL_ELECTING:
return vote_reply_decode_and_dispatch(ep, vctx);
@ -3275,6 +3366,21 @@ int peer_ctrl_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt)
return peer_ctrl_msg_handler(ep);
}
/*
* pre_vote_reply_msg_handler_for_test: drive a preVoteReply through the REAL
* decode/route path (peer_ctrl_msg_handler) while the candidate is CTRL_STANDBY
* with a PreVote round in progress — the exact production condition under which
* a PreVote reply arrives. Unlike vote_reply_msg_handler_for_test it does NOT
* force the role: it proves the STANDBY+pre_vote_in_progress branch added in
* task 5.1 actually routes the reply to the PreVote tally (instead of dropping
* it). Caller owns ep->data (vote_ep_ctx_t); pkt ownership is transferred.
*/
int pre_vote_reply_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt)
{
ep->fsm_in.pkt = pkt;
return peer_ctrl_msg_handler(ep);
}
/*
* vote_ep_ctx_set_cluster_for_test: set the cluster_t pointer inside the
* vote_ep_ctx_t stored at ep->data. Tests that need the promoted-endpoint
@ -3700,11 +3806,10 @@ void ha_start_prevote_async(ha_state_t *ha, cluster_t *c)
* Mirrors vote_reply_dispatch → raft_on_vote_reply, but the raft library makes
* NO role/term change here — the controller acts on the returned status.
*
* In 4.1 the only caller is the XCE_TESTING reply seam; the production caller
* is the preVoteReply transport dispatch wired in task 5.1. __attribute__((used))
* keeps the production build clean (no -Wunused-function) until then.
* Production caller (task 5.1): pre_vote_reply_decode_and_dispatch, reached from
* peer_ctrl_msg_handler when a preVoteReply arrives on the outbound PreVote
* endpoint. Also driven by the XCE_TESTING reply seam.
*/
__attribute__((used))
static void pre_vote_reply_dispatch(ha_state_t *ha, cluster_t *c,
int peer_idx, int granted)
{