feat(ha-prevote): controller voter-side PreVote dispatch (task 3.1)

Add OPCode_preVoteRequest case in cluster.c, mirroring the real-vote case:
freshness floor + reused eligibility/stickiness gate (raft_on_pre_vote_request),
reply over preVoteReply with the voter's UNCHANGED term (no adoption), denial
logging with candidate id + reason. Pure insertion; real-vote case untouched.
6 new vote-gate tests assert deny-while-leader-alive, grant-when-lapsed, and the
no-mutation/term-unchanged contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-06-16 05:21:12 +00:00
parent 9fac681510
commit 6f95e4cdfc
3 changed files with 444 additions and 2 deletions

View file

@ -437,6 +437,131 @@ static int myreq_handler(vtask_t* task)
rc = 0;
break;
}
case OPCode_preVoteRequest: {
/* Task 3.1 (ha-prevote): answer an incoming PreVote straw-poll.
*
* A PreVote asks "would you vote for me in cand_term (= sender's
* current term + 1)?" WITHOUT any side effect. This case mirrors the
* OPCode_requestVote case above but is strictly NON-MUTATING:
* - it NEVER adopts cand_term (PreVote exists precisely to stop a
* temporarily-unreachable peer from inflating the cluster term);
* - it records NO vote (raft_on_pre_vote_request writes nothing);
* - it never persists.
* The reply therefore carries the voter's CURRENT term UNCHANGED, on
* both grant and deny (Req 2.3, 6.2).
*
* Decision order (identical gates to the real-vote path, reused — Req
* 2.1, 2.2, 3.1, 3.2, 3.3):
* 1. freshness floor (ha_vote_self_freshness): deny if candidate is
* below max(local, learned-committed) config_tag/LSN;
* 2. raft_on_pre_vote_request: stale-term deny + the SHARED
* raft_cb_vote_eligible gate (PRIMARY-deny / fresh-heartbeat
* stickiness / catch-up deny).
* Every denial is logged with the candidate id + reason (Req 7.2). */
request_vote_req_t *vrq = request.body.requestvotemsg;
uint64_t cand_tag = vrq ? vrq->config_tag : 0;
uint64_t cand_lsn = vrq ? vrq->last_log_lsn : 0;
uint64_t cand_term = vrq ? vrq->term : 0;
uint64_t local_tag = c->myconf->config_version_tag;
uint64_t local_lsn = (uint64_t)atomic_load_explicit(
&c->ha.applied_lsn, memory_order_acquire);
/* Effective freshness floor: max of local state and learned-committed
* cluster state (election-safety fix #2, reused unchanged). */
uint64_t my_tag = 0, my_lsn = 0;
ha_vote_self_freshness(&c->ha, local_tag, local_lsn, &my_tag, &my_lsn);
int freshness_ok = !(cand_tag < my_tag ||
(cand_tag == my_tag && cand_lsn < my_lsn));
/* Map candidate name → integer peer index (same loop as real-vote). */
int from_idx = -1;
const char *cand_id = vrq ? vrq->candidate_id : "";
if (cand_id && cand_id[0] != '\0') {
const xce_cluster_config_t *pcfg = &c->myconf->peers_cfg;
for (int i = 0; i < pcfg->candidates_count; i++) {
if (strcmp(pcfg->candidates[i].name, cand_id) == 0) {
from_idx = i;
break;
}
}
}
/* PreVote NEVER adopts the candidate term: reply with our CURRENT term,
* read once up front so grant and deny paths share it. */
uint64_t reply_term = atomic_load_explicit(&c->ha.term,
memory_order_acquire);
const char *deny_reason = NULL;
int granted = 0;
if (!freshness_ok) {
deny_reason = "freshness";
} else if (from_idx < 0) {
deny_reason = "unknown_candidate";
} else {
/* raft_on_pre_vote_request is pure: stale-term + eligibility gate,
* no term/voted_for/role mutation, no persist. */
int r = raft_on_pre_vote_request(&c->ha.raft, from_idx, cand_term);
if (r == RAFT_GRANT) {
granted = 1;
} else {
/* Distinguish the denial reason for operator diagnosis
* (Req 7.2). raft_on_pre_vote_request denies on (a) stale term
* — cand_term < raft.term — or (b) the shared eligibility gate
* (raft_cb_vote_eligible): PRIMARY-deny, fresh-heartbeat
* stickiness, or catch-up. We re-derive which fired WITHOUT
* calling the file-static heartbeat-fresh predicate: when the
* role is STANDBY and the node is neither catching up nor at a
* stale term, the only remaining eligibility denial is fresh
* heartbeat (a stale heartbeat would have passed the gate). */
uint64_t my_raft_term = c->ha.raft.term;
if (cand_term < my_raft_term) {
deny_reason = "stale_term";
} else {
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(
&c->ha.role, memory_order_acquire);
if (role == CTRL_PRIMARY)
deny_reason = "primary";
else if (atomic_load_explicit(&c->ha.in_catch_up,
memory_order_acquire))
deny_reason = "catch_up";
else if (role == CTRL_STANDBY)
deny_reason = "fresh_heartbeat";
else
deny_reason = "eligibility";
}
}
}
if (!granted) {
INFO("HA: PreVote DENIED candidate='%s' term=%" PRIu64
" reason=%s (my_term=%" PRIu64 ", floor tag=%" PRIu64
" lsn=%" PRIu64 ")",
(cand_id && cand_id[0]) ? cand_id : "<unknown>",
cand_term, deny_reason, reply_term, my_tag, my_lsn);
}
request_vote_reply_t pv_rep;
memset(&pv_rep, 0, sizeof(pv_rep));
pv_rep.term = reply_term; /* UNCHANGED — no adoption */
pv_rep.vote_granted = granted;
pv_rep.peer_config_tag = my_tag;
pv_rep.peer_last_log_lsn = my_lsn;
/* Reply over the dedicated preVoteReply opcode so the candidate's
* reply router (task 5.1) feeds it to the PreVote tally, never the
* real-vote tally. */
reply.opcode = OPCode_preVoteReply;
reply.status = 0;
reply.data_which = RequestReply_data_requestvotereplymsg;
reply.data.requestvotereplymsg = &pv_rep;
xce_reply(ep, c->myhost, &reply);
/* Inbound PreVote connections are vote-only, like real-vote: leave the
* connection open; no state push on the reply. */
rc = 0;
break;
}
case OPCode_replConfig:
rc = handle_config_request(c, ep, &request);
break;