forked from lei.chen/myxce
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:
parent
9fac681510
commit
6f95e4cdfc
3 changed files with 444 additions and 2 deletions
|
|
@ -39,7 +39,7 @@
|
|||
- _Depends: 1.2_
|
||||
|
||||
- [ ] 3. Core: controller voter-side PreVote dispatch (test-first)
|
||||
- [ ] 3.1 Answer incoming PreVote requests with stickiness + freshness, no mutation
|
||||
- [x] 3.1 Answer incoming PreVote requests with stickiness + freshness, no mutation
|
||||
- RED first: extend the vote-gate tests so a PreVote from a fresh candidate is denied while a leader is believed alive (primary role, or standby with fresh heartbeat, or catch-up) and granted once stickiness lapses and freshness passes.
|
||||
- Compute the freshness floor and run the eligibility gate before the raft decision, reusing the real-vote dispatch shape; reply with the voter's unchanged term plus the grant bit and freshness fields; log every denial with candidate id and reason.
|
||||
- Observable: a denied PreVote reply carries the voter's original term (no term adoption) and a diagnosable denial-reason log line is emitted.
|
||||
|
|
@ -94,3 +94,5 @@
|
|||
## Implementation Notes
|
||||
- (1.1) Wire opcodes `preVoteRequest @38` / `preVoteReply @39` reuse existing `RequestVoteRequest`/`RequestVoteReply` structs and union arms (`requestvotemsg @27` / `requestvotereplymsg @14`). Generated bindings live under `src/xce.build/lib/controller.capnp.h` (build artifacts, not source-committed).
|
||||
- (1.2) Raft PreVote scaffolding in `raft.h`: outcome codes `RAFT_PRE_VOTE_WON/LOST/PENDING` = 7/8/9; tally fields `pre_vote_in_progress`, `pre_votes_granted`, `pre_vote_replies[RAFT_MAX_PEERS]` appended to `raft_state_t` (after `my_priority`); optional `on_send_pre_vote_request` callback (NULL-checkable). New `#define RAFT_MAX_PEERS 8` bounds the per-peer dedup array — safe: production cap is `MAX_HA_CANDIDATES=4` (src/include/config.h:78), and it matches `RAFT_SIM_MAX_NODES=8`. Tasks 2.1/2.2/2.3 supply the function bodies and MUST keep them pure (no `term`/`voted_for`/`role` mutation, no `on_persist`). Build/test the raft layer with `cd src && nix develop --command bash tools/xce-build.sh lib`.
|
||||
- (3.1) IMPORTANT for controller tasks: `cluster.c` is NOT linked into the unit-test binary (`controller_unit_test_src` in `src/controller/testsuite/meson.build`) — it's daemon-only (`controller_src`). So dispatch cases in cluster.c can't be unit-tested directly; test their composed primitives + contract asserts, and defer end-to-end to scenario tasks 5.2/5.3. `ha.c` IS unit-test-linked (the `test_ha_*` suites exercise it). Controller build/test: `cd src && nix develop --command bash tools/xce-build.sh controller`; the registered meson test name is `"controller test"` (single Unity binary). Known pre-existing `test_commit_barrier_reply` 300s TIMEOUT makes the full suite exit non-zero — validate via the targeted run `nix develop --command meson test -C xce.build --no-rebuild --print-errorlogs "controller test"` and check for zero `:FAIL` (Timeout:1 is the known one). The new prevote opcodes' generated constants are `OPCode_preVoteRequest=38` / `OPCode_preVoteReply=39`.
|
||||
- (3.1, LOW nit) cluster.c PreVote denial LOG reason uses elimination order (stale_term→primary→catch_up→fresh_heartbeat) which differs from `raft_cb_vote_eligible`'s gate order (primary→fresh_heartbeat→catch_up); in the rare STANDBY catch-up+fresh-heartbeat overlap the log string may say `catch_up` while the gate denied on `fresh_heartbeat`. Decision is identical (deny); log-string only. Optional future polish: reorder to match.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
#include <unity/unity.h>
|
||||
|
||||
#include "controller.capnp.h"
|
||||
#include "ha.h"
|
||||
#include "ha.h" /* pulls in raft/raft.h: raft_state_t, RAFT_GRANT,
|
||||
* raft_on_pre_vote_request, RAFT_PRE_VOTE_* */
|
||||
|
||||
/* Functions under test — declared here to match their signatures in ha.c */
|
||||
int handle_request_vote(ha_state_t *ha,
|
||||
|
|
@ -431,6 +432,311 @@ static void test_no_learned_state_falls_back_to_local_gate(void)
|
|||
printf("test_no_learned_state_falls_back_to_local_gate: PASS\n");
|
||||
}
|
||||
|
||||
/* ══ Task 3.1: voter-side PreVote dispatch gate (cluster.c OPCode_preVoteRequest) ══
|
||||
*
|
||||
* The PreVote dispatch case in cluster.c is a thin composition of three
|
||||
* primitives that all live in the unit-test binary:
|
||||
* 1. ha_vote_self_freshness() — the freshness floor (Req 2.1, 2.2)
|
||||
* 2. candidate-name → peer index mapping
|
||||
* 3. raft_on_pre_vote_request() — stale-term + the SHARED eligibility gate
|
||||
* (raft_cb_vote_eligible: PRIMARY-deny / fresh-heartbeat-deny / catch-up-deny;
|
||||
* Req 3.1, 3.2, 3.3)
|
||||
* cluster.c is not linked into the unit-test binary (transport deps), so we
|
||||
* exercise the EXACT decision the dispatch composes via this mirror helper, then
|
||||
* assert the dispatch's observable contract: the reply term is the voter's
|
||||
* CURRENT term UNCHANGED (no term adoption) and no persistent state mutates.
|
||||
*
|
||||
* pre_vote_dispatch_decide reproduces cluster.c's OPCode_preVoteRequest decision
|
||||
* byte-for-byte: freshness floor, then raft_on_pre_vote_request, with the reply
|
||||
* term pinned to the voter's current term on every path (grant OR deny). */
|
||||
|
||||
static int pre_vote_dispatch_decide(ha_state_t *ha,
|
||||
uint64_t cand_term,
|
||||
uint64_t cand_tag,
|
||||
uint64_t cand_lsn,
|
||||
uint64_t local_tag,
|
||||
uint64_t local_lsn,
|
||||
int from_idx,
|
||||
uint64_t *out_reply_term,
|
||||
uint64_t *out_my_tag,
|
||||
uint64_t *out_my_lsn)
|
||||
{
|
||||
/* Freshness floor: max(local, learned-committed), identical to the real-vote
|
||||
* dispatch case. */
|
||||
uint64_t my_tag = 0, my_lsn = 0;
|
||||
ha_vote_self_freshness(ha, local_tag, local_lsn, &my_tag, &my_lsn);
|
||||
|
||||
int freshness_ok = !(cand_tag < my_tag ||
|
||||
(cand_tag == my_tag && cand_lsn < my_lsn));
|
||||
|
||||
int granted = 0;
|
||||
if (freshness_ok && from_idx >= 0)
|
||||
granted = (raft_on_pre_vote_request(&ha->raft, from_idx, cand_term)
|
||||
== RAFT_GRANT) ? 1 : 0;
|
||||
|
||||
/* PreVote NEVER adopts the candidate term: the reply carries the voter's
|
||||
* CURRENT term unchanged, on grant OR deny. */
|
||||
if (out_reply_term)
|
||||
*out_reply_term =
|
||||
atomic_load_explicit(&ha->term, memory_order_acquire);
|
||||
if (out_my_tag) *out_my_tag = my_tag;
|
||||
if (out_my_lsn) *out_my_lsn = my_lsn;
|
||||
return granted;
|
||||
}
|
||||
|
||||
/* Snapshot of every field the PreVote handler must leave UNCHANGED. */
|
||||
typedef struct {
|
||||
uint64_t term;
|
||||
uint64_t raft_term;
|
||||
uint64_t voted_for_term;
|
||||
char voted_for_name[XCE_MAXNAME_LEN];
|
||||
int raft_voted_for;
|
||||
int32_t role;
|
||||
} prevote_state_snapshot_t;
|
||||
|
||||
static void snapshot_prevote_state(ha_state_t *ha, prevote_state_snapshot_t *s)
|
||||
{
|
||||
s->term = atomic_load_explicit(&ha->term, memory_order_acquire);
|
||||
s->raft_term = ha->raft.term;
|
||||
s->voted_for_term = ha->voted_for_term;
|
||||
memcpy(s->voted_for_name, ha->voted_for_name, sizeof(s->voted_for_name));
|
||||
s->raft_voted_for = ha->raft.voted_for;
|
||||
s->role = atomic_load_explicit(&ha->role, memory_order_acquire);
|
||||
}
|
||||
|
||||
static void assert_prevote_state_unchanged(ha_state_t *ha,
|
||||
const prevote_state_snapshot_t *before)
|
||||
{
|
||||
prevote_state_snapshot_t after;
|
||||
snapshot_prevote_state(ha, &after);
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(before->term, after.term,
|
||||
"PreVote must NOT change ha->term (no term adoption)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(before->raft_term, after.raft_term,
|
||||
"PreVote must NOT change raft.term (no term adoption)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(before->voted_for_term, after.voted_for_term,
|
||||
"PreVote must NOT change voted_for_term (records no vote)");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0,
|
||||
memcmp(before->voted_for_name, after.voted_for_name,
|
||||
sizeof(after.voted_for_name)),
|
||||
"PreVote must NOT change voted_for_name (records no vote)");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(before->raft_voted_for, after.raft_voted_for,
|
||||
"PreVote must NOT change raft.voted_for (records no vote)");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(before->role, after.role,
|
||||
"PreVote must NOT change role");
|
||||
}
|
||||
|
||||
/* ── Req 3.3: CTRL_PRIMARY denies a PreVote regardless of candidate freshness ─ */
|
||||
|
||||
static void test_pre_vote_primary_role_denies(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 5ULL, memory_order_release);
|
||||
ha.raft.term = 5ULL;
|
||||
ha.voted_for_term = 0;
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
5ULL, /* cand_term */
|
||||
100ULL, /* cand_tag — perfectly fresh */
|
||||
50ULL, /* cand_lsn */
|
||||
100ULL, /* local tag */
|
||||
50ULL, /* local lsn */
|
||||
0, /* from_idx */
|
||||
&reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"CTRL_PRIMARY must deny all PreVote requests (Req 3.3)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_primary_role_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 3.1: CTRL_STANDBY + fresh heartbeat denies a fresh PreVote ─────────── */
|
||||
|
||||
static void test_pre_vote_standby_fresh_heartbeat_denies(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 2ULL, memory_order_release);
|
||||
ha.raft.term = 2ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = 999; /* live primary connection */
|
||||
set_last_activity_age(&ha, 500L); /* fresh heartbeat (< 10 s) */
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
3ULL, /* cand wants term+1 = 3 */
|
||||
100ULL, 50ULL, /* candidate is fresh */
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"STANDBY with fresh heartbeat must deny PreVote — leader believed alive (Req 3.1)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(2ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_standby_fresh_heartbeat_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 3.1/3.2: catch-up standby denies a PreVote ────────────────────────── */
|
||||
|
||||
static void test_pre_vote_catch_up_denies(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 4ULL, memory_order_release);
|
||||
ha.raft.term = 4ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale heartbeat → not sticky */
|
||||
atomic_store_explicit(&ha.in_catch_up, 1, memory_order_release);
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
5ULL,
|
||||
100ULL, 50ULL, /* fresh candidate */
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"a catching-up standby must deny PreVote (Req 3.x)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(4ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_catch_up_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 2.2: stale-freshness candidate denied even when stickiness lapsed ──── */
|
||||
|
||||
static void test_pre_vote_stale_freshness_denies(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 1ULL, memory_order_release);
|
||||
ha.raft.term = 1ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale — stickiness lapsed */
|
||||
|
||||
/* Learned-committed floor: config_tag=100. */
|
||||
atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release);
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
2ULL,
|
||||
50ULL, /* cand_tag below learned floor 100 */
|
||||
999ULL, /* high lsn — irrelevant */
|
||||
0ULL, /* local cold */
|
||||
0ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"candidate below the freshness floor must be DENIED (Req 2.2)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(1ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_stale_freshness_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 2.3: stale-term candidate denied, no term adoption ─────────────────── */
|
||||
|
||||
static void test_pre_vote_stale_term_denies_no_adoption(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 5ULL, memory_order_release);
|
||||
ha.raft.term = 5ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L);
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
3ULL, /* cand_term < raft.term (5) → stale */
|
||||
100ULL, 50ULL,
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"a stale-term PreVote candidate must be DENIED (Req 2.3)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5ULL, reply_term,
|
||||
"stale-term PreVote reply carries the voter's UNCHANGED higher term");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_stale_term_denies_no_adoption: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 2.1/3.2/5.1: stickiness lapsed + fresh candidate → GRANT, no mutation ─ */
|
||||
|
||||
static void test_pre_vote_granted_when_stickiness_lapsed_and_fresh(void)
|
||||
{
|
||||
/* The genuine-failover case: no live primary, heartbeat stale (stickiness
|
||||
* lapsed), candidate proposes term+1 and meets the freshness floor → GRANT.
|
||||
* The grant must still mutate NOTHING (no voted_for, no term, no role). */
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 3ULL, memory_order_release);
|
||||
ha.raft.term = 3ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale — stickiness lapsed */
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0, my_tag = 0, my_lsn = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
4ULL, /* prospective term = current+1 */
|
||||
100ULL, 50ULL, /* meets local floor */
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, &my_tag, &my_lsn);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
|
||||
"fresh candidate after stickiness lapse must be GRANTED a PreVote (Req 5.1)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(3ULL, reply_term,
|
||||
"granted PreVote reply STILL carries the voter's UNCHANGED term (no adoption)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(100ULL, my_tag,
|
||||
"reply must carry the voter's freshness config_tag");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(50ULL, my_lsn,
|
||||
"reply must carry the voter's freshness last_log_lsn");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_granted_when_stickiness_lapsed_and_fresh: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Test suite entry point ───────────────────────────────────────────────── */
|
||||
|
||||
void test_ha_vote_gate(void)
|
||||
|
|
@ -456,4 +762,13 @@ void test_ha_vote_gate(void)
|
|||
RUN_TEST(test_learned_committed_lsn_denies_lower_lsn_candidate);
|
||||
RUN_TEST(test_uptodate_candidate_still_granted_with_learned_floor);
|
||||
RUN_TEST(test_no_learned_state_falls_back_to_local_gate);
|
||||
|
||||
/* Task 3.1 — voter-side PreVote dispatch gate (stickiness + freshness,
|
||||
* no mutation, reply term unchanged) */
|
||||
RUN_TEST(test_pre_vote_primary_role_denies);
|
||||
RUN_TEST(test_pre_vote_standby_fresh_heartbeat_denies);
|
||||
RUN_TEST(test_pre_vote_catch_up_denies);
|
||||
RUN_TEST(test_pre_vote_stale_freshness_denies);
|
||||
RUN_TEST(test_pre_vote_stale_term_denies_no_adoption);
|
||||
RUN_TEST(test_pre_vote_granted_when_stickiness_lapsed_and_fresh);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue