fix(ha-config-bootstrap,ha-repl-hardening): config-less node must not win election
Root cause (found via ha-convergence-recovery task 8 live 3-node E2E): a controller with no local config (config_version_tag==0) could become PRIMARY and push an empty config, breaking replication. Two upstream election-safety gaps, both outside ha-convergence-recovery: 1. RDHUP re-election bypassed the no-config 'voter only' guard. The startup path (ha_start_election_async) refuses to start an election when my_config_tag==0 && n_peers>1, but the RDHUP failover path (ha_on_primary_ctrl_fd_rdhup) called raft_on_leader_lost unconditionally. Fix: mirror the guard before elect_ctx alloc / hook / raft_on_leader_lost; a config-less node stays CTRL_STANDBY (voter only) and grants the configured peer instead of starting (and winning) its own election. 2. Voter freshness gate compared the candidate only against the voter's LOCAL config, ignoring the cluster's known committed state. Fix: ha_vote_self_freshness floors the comparison at max(local, learned-committed config_tag / committed_lsn), so a voter denies any candidate whose config/cluster state is outdated vs what the cluster has committed. Strictly additive (never denies the freshest node — Leader Completeness preserves liveness). TDD: test_rdhup_noconfig_node_does_not_elect + configured-node contrast; test_ha_vote_gate learned-floor deny + liveness + backward-compat. Full controller suite green under ASAN. commit.c and ha-convergence-recovery files untouched.
This commit is contained in:
parent
513b965f98
commit
c97c7632e9
6 changed files with 439 additions and 9 deletions
|
|
@ -377,11 +377,19 @@ static int myreq_handler(vtask_t* task)
|
|||
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 my_tag = c->myconf->config_version_tag;
|
||||
uint64_t my_lsn = (uint64_t)atomic_load_explicit(
|
||||
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);
|
||||
|
||||
/* Freshness gate: deny if candidate is not at least as fresh as us.
|
||||
/* Election-safety fix #2: the freshness floor is the max of our local
|
||||
* state and our learned-committed cluster state, so we deny candidates
|
||||
* that are outdated against what the cluster has already committed
|
||||
* (not merely against our local config file / applied LSN). */
|
||||
uint64_t my_tag = 0, my_lsn = 0;
|
||||
ha_vote_self_freshness(&c->ha, local_tag, local_lsn, &my_tag, &my_lsn);
|
||||
|
||||
/* Freshness gate: deny if candidate is not at least as fresh as the
|
||||
* effective floor.
|
||||
* (Req 2.4: config_tag must be ≥ ours; if equal, log_lsn must be ≥ ours.) */
|
||||
int freshness_ok = !(cand_tag < my_tag ||
|
||||
(cand_tag == my_tag && cand_lsn < my_lsn));
|
||||
|
|
|
|||
|
|
@ -1982,6 +1982,30 @@ static int ha_heartbeat_is_fresh(ha_state_t *ha)
|
|||
return ms < (long)ha->config.heartbeat_timeout_ms;
|
||||
}
|
||||
|
||||
/* ── Voter freshness floor (election safety) ───────────────────────────────
|
||||
*
|
||||
* Compute the voter's effective freshness floor as the per-field max of its
|
||||
* local state and its learned-committed cluster state. Outdated candidates
|
||||
* (below this floor) are denied so the cluster can never be rolled back below
|
||||
* what it has already committed. See ha.h for full contract.
|
||||
*/
|
||||
void ha_vote_self_freshness(ha_state_t *ha,
|
||||
uint64_t local_config_tag,
|
||||
uint64_t local_applied_lsn,
|
||||
uint64_t *out_tag,
|
||||
uint64_t *out_lsn)
|
||||
{
|
||||
uint64_t learned_tag =
|
||||
atomic_load_explicit(&ha->committed_config_tag, memory_order_acquire);
|
||||
uint64_t learned_lsn =
|
||||
atomic_load_explicit(&ha->last_known_committed_lsn, memory_order_acquire);
|
||||
|
||||
if (out_tag)
|
||||
*out_tag = (local_config_tag > learned_tag) ? local_config_tag : learned_tag;
|
||||
if (out_lsn)
|
||||
*out_lsn = (local_applied_lsn > learned_lsn) ? local_applied_lsn : learned_lsn;
|
||||
}
|
||||
|
||||
/* ── Vote Handler (Req 2.2–2.6, 6.1, 6.2, 6.4, 9.1) ────────────────────────
|
||||
*
|
||||
* handle_request_vote: process an incoming OP_requestVote RPC.
|
||||
|
|
@ -2073,15 +2097,20 @@ int handle_request_vote(ha_state_t *ha,
|
|||
return 0;
|
||||
}
|
||||
|
||||
/* Req 2.4: freshness gate
|
||||
* Deny if candidate has a lower config_tag, or equal config_tag with lower LSN.
|
||||
* Grant if candidate is strictly fresher or equally fresh.
|
||||
*/
|
||||
if (config_tag < my_config_tag) {
|
||||
/* Req 2.4 + election-safety fix #2: freshness gate against the EFFECTIVE
|
||||
* floor — the max of the voter's local state and its learned-committed
|
||||
* cluster state. A candidate that is below what the cluster has already
|
||||
* committed (config_tag or, at equal tag, LSN) would roll the cluster back
|
||||
* and must be denied even when this voter's local state is colder.
|
||||
* Deny if candidate has a lower config_tag, or equal config_tag with lower
|
||||
* LSN; grant if strictly fresher or equally fresh. */
|
||||
uint64_t eff_tag = 0, eff_lsn = 0;
|
||||
ha_vote_self_freshness(ha, my_config_tag, my_last_lsn, &eff_tag, &eff_lsn);
|
||||
if (config_tag < eff_tag) {
|
||||
pthread_mutex_unlock(&ha->lock);
|
||||
return 0;
|
||||
}
|
||||
if (config_tag == my_config_tag && last_lsn < my_last_lsn) {
|
||||
if (config_tag == eff_tag && last_lsn < eff_lsn) {
|
||||
pthread_mutex_unlock(&ha->lock);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -3423,6 +3452,34 @@ void ha_on_primary_ctrl_fd_rdhup(ha_state_t *ha, cluster_t *c)
|
|||
if (committed > 0)
|
||||
persist_truncate_wal_above(&c->persist, committed);
|
||||
|
||||
/* Election-SAFETY guard (ha-config-bootstrap Req 2.8 / ha-repl-hardening
|
||||
* Req 10.5): a config-less node (config_version_tag == 0) must NEVER start
|
||||
* an RDHUP re-election while a configured peer exists. Two such nodes would
|
||||
* grant each other (the freshness gate only denies a LOWER tag; tag0==tag0
|
||||
* with equal lsn is granted), forming a majority and electing a config-less
|
||||
* primary over the node that actually holds the config — which then pushes
|
||||
* an empty config and breaks replication.
|
||||
*
|
||||
* Mirror the proven ha_start_election_async voter-only guard: skip the
|
||||
* elect_ctx alloc + the election trigger (raft_on_leader_lost) entirely and
|
||||
* stay a passive voter (CTRL_STANDBY). handle_request_vote still GRANTS
|
||||
* incoming vote requests to the fresher configured peer regardless of role,
|
||||
* and this node transitions/fetches config when the configured winner
|
||||
* connects and pushes config via ctrlhello.
|
||||
*
|
||||
* Single-node (candidates_count == 1) is unaffected: n_peers > 1 is false,
|
||||
* so a lone node still self-elects regardless of tag. */
|
||||
uint64_t my_config_tag = (c->myconf) ? c->myconf->config_version_tag : 0;
|
||||
int n_peers_guard = (c->myconf) ? c->myconf->peers_cfg.candidates_count : 1;
|
||||
if (my_config_tag == 0 && n_peers_guard > 1) {
|
||||
INFO("ha_on_primary_ctrl_fd_rdhup: no local config (tag=0, n_peers=%d) — "
|
||||
"voter only, not starting RDHUP re-election; awaiting configured "
|
||||
"primary (ha-config-bootstrap Req 2.8)", n_peers_guard);
|
||||
atomic_store_explicit(&ha->role, (int32_t)CTRL_STANDBY,
|
||||
memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Task 6.3 / 6.7 fix: delegate RDHUP election trigger to raft library.
|
||||
*
|
||||
* raft_on_leader_lost:
|
||||
|
|
|
|||
|
|
@ -657,6 +657,27 @@ int handle_request_vote(ha_state_t *ha,
|
|||
int in_catch_up_flag);
|
||||
#endif
|
||||
|
||||
/*
|
||||
* ha_vote_self_freshness: compute the voter's effective freshness floor.
|
||||
*
|
||||
* Election safety: a voter must deny candidates that are outdated against the
|
||||
* strongest knowledge it has of the cluster's COMMITTED state — not merely its
|
||||
* own local config file / applied LSN. The floor is the per-field max of the
|
||||
* voter's local state and its learned-committed state:
|
||||
*
|
||||
* *out_tag = max(local_config_tag, atomic committed_config_tag)
|
||||
* *out_lsn = max(local_applied_lsn, atomic last_known_committed_lsn)
|
||||
*
|
||||
* Uses acquire loads of the atomic learned-committed fields; never reads the
|
||||
* non-atomic last_known_committed_config_tag (data-race safe). Pure aside from
|
||||
* the atomic reads — no state mutation.
|
||||
*/
|
||||
void ha_vote_self_freshness(ha_state_t *ha,
|
||||
uint64_t local_config_tag,
|
||||
uint64_t local_applied_lsn,
|
||||
uint64_t *out_tag,
|
||||
uint64_t *out_lsn);
|
||||
|
||||
/*
|
||||
* ha_elect_evaluate_and_signal: evaluate accumulated ha_elect_ctx_t votes.
|
||||
* Sets ctx->result (1=primary, 0=standby) and posts ctx->done semaphore.
|
||||
|
|
|
|||
|
|
@ -291,6 +291,14 @@ static void test_rdhup_calls_start_election_async(void)
|
|||
myconf_t myconf;
|
||||
memset(&myconf, 0, sizeof(myconf));
|
||||
myconf.name = "ctrl-a";
|
||||
/* Configured node: a real (nonzero) config tag so the no-config voter-only
|
||||
* RDHUP guard does NOT trip — this test exercises the legitimate "a
|
||||
* CONFIGURED node re-elects after RDHUP" path (ha-config-bootstrap Req 2.8).
|
||||
* Without a tag, config_version_tag==0 with candidates_count>1 would (post
|
||||
* election-safety fix) keep the node a passive voter (CTRL_STANDBY) and the
|
||||
* election hook would never fire — that was the buggy condition this test
|
||||
* incidentally relied on. */
|
||||
myconf.config_version_tag = 0x1234;
|
||||
myconf.peers_cfg.candidates_count = 2;
|
||||
snprintf(myconf.peers_cfg.candidates[0].name,
|
||||
sizeof(myconf.peers_cfg.candidates[0].name), "ctrl-a");
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ static int stub_ctrlhello(void *c) { (void)c; g_ctrlhello_calls++; return 0;
|
|||
* longer call it (production ha_elect_evaluate_and_signal now lands the winner
|
||||
* directly per task 5.1 territory). */
|
||||
|
||||
/* A real (nonzero) config tag, so a node is treated as CONFIGURED and is
|
||||
* allowed to start an RDHUP re-election. The no-config voter-only guard in
|
||||
* ha_on_primary_ctrl_fd_rdhup only trips for config_version_tag == 0 with
|
||||
* candidates_count > 1 (ha-config-bootstrap Req 2.8 / ha-repl-hardening Req 10.5). */
|
||||
#define TEST_CONFIG_TAG 0x1234
|
||||
|
||||
/* ── Shared HA config ───────────────────────────────────────────────────── */
|
||||
|
||||
static ha_config_t g_cfg = {
|
||||
|
|
@ -144,6 +150,11 @@ static void make_cluster(cluster_t *c, myconf_t *myconf, const char *my_name)
|
|||
memset(c, 0, sizeof(*c));
|
||||
memset(myconf, 0, sizeof(*myconf));
|
||||
myconf->name = (char *)my_name;
|
||||
/* Configured node by default: a real (nonzero) tag so the no-config
|
||||
* voter-only RDHUP guard does NOT trip — these fixtures exercise the
|
||||
* legitimate "a CONFIGURED node re-elects after RDHUP" path. The
|
||||
* config-less guard is exercised by its own dedicated test below. */
|
||||
myconf->config_version_tag = TEST_CONFIG_TAG;
|
||||
myconf->peers_cfg.candidates_count = 2;
|
||||
snprintf(myconf->peers_cfg.candidates[0].name,
|
||||
sizeof(myconf->peers_cfg.candidates[0].name), "%s", my_name);
|
||||
|
|
@ -373,6 +384,118 @@ static void test_rdhup_primary_dispatch_gate_opens(void)
|
|||
printf("test_rdhup_primary_dispatch_gate_opens: PASS (gate accepts job ops)\n");
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* Test 7: config-less node (tag=0) with peers MUST NOT start an RDHUP re-election
|
||||
*
|
||||
* Election-SAFETY (ha-config-bootstrap Req 2.8 / ha-repl-hardening Req 10.5):
|
||||
* a node with no local config (config_version_tag == 0) and >1 candidates must
|
||||
* NOT win an election while a configured peer exists. ha_on_primary_ctrl_fd_rdhup
|
||||
* must mirror the ha_start_election_async voter-only guard: skip the elect_ctx
|
||||
* alloc + the election trigger entirely and stay a passive voter (CTRL_STANDBY).
|
||||
*
|
||||
* Asserted via the ha_rdhup_election_hook seam: the hook must NOT fire (count==0),
|
||||
* and role must be CTRL_STANDBY (not CTRL_ELECTING / CTRL_PRIMARY).
|
||||
* Before the fix this FAILS: the hook fires and the election proceeds.
|
||||
* ═════════════════════════════════════════════════════════════════════════ */
|
||||
static int g_guard_hook_calls = 0;
|
||||
|
||||
static void counting_election_hook(ha_state_t *ha, cluster_t *c,
|
||||
int n_peers, xce_candidate_t **peers,
|
||||
uint64_t my_config_tag, uint64_t my_lsn,
|
||||
const char *my_name)
|
||||
{
|
||||
(void)ha; (void)c; (void)n_peers; (void)peers;
|
||||
(void)my_config_tag; (void)my_lsn; (void)my_name;
|
||||
g_guard_hook_calls++;
|
||||
}
|
||||
|
||||
static void test_rdhup_noconfig_node_does_not_elect(void)
|
||||
{
|
||||
install_stubs();
|
||||
g_guard_hook_calls = 0;
|
||||
|
||||
cluster_t c;
|
||||
myconf_t myconf;
|
||||
make_cluster(&c, &myconf, "ctrl-a");
|
||||
/* Config-less node: no local config tag, but part of a >1 node cluster. */
|
||||
myconf.config_version_tag = 0;
|
||||
myconf.peers_cfg.candidates_count = 2;
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0));
|
||||
ha_do_demote(&c.ha);
|
||||
c.ha.phase2_done = 1;
|
||||
c.ha.n_peers = 1;
|
||||
|
||||
ha_rdhup_election_hook = counting_election_hook;
|
||||
|
||||
int sv[2];
|
||||
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
|
||||
c.ha.primary_ctrl_fd = sv[0];
|
||||
|
||||
ha_on_primary_ctrl_fd_rdhup(&c.ha, &c);
|
||||
|
||||
clear_stubs();
|
||||
close(sv[1]);
|
||||
|
||||
/* The election hook must NOT have fired — no re-election started. */
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_guard_hook_calls,
|
||||
"config-less node (tag=0, peers>1) must NOT start an RDHUP re-election "
|
||||
"(ha-config-bootstrap Req 2.8)");
|
||||
|
||||
/* Role must be CTRL_STANDBY — a passive voter awaiting the configured primary. */
|
||||
int role = (int)atomic_load_explicit(&c.ha.role, memory_order_acquire);
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_STANDBY, role,
|
||||
"config-less RDHUP node must stay CTRL_STANDBY (voter only), not ELECTING/PRIMARY");
|
||||
|
||||
ha_fini(&c.ha);
|
||||
printf("test_rdhup_noconfig_node_does_not_elect: PASS "
|
||||
"(hook_calls=%d, role=%d==CTRL_STANDBY)\n", g_guard_hook_calls, role);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* Test 8 (contrast / non-regression): a CONFIGURED node (tag!=0) with peers
|
||||
* MUST still start the RDHUP re-election.
|
||||
*
|
||||
* Same shape as Test 7 but with a real config tag — the guard must NOT trip.
|
||||
* The election hook MUST fire exactly once.
|
||||
* ═════════════════════════════════════════════════════════════════════════ */
|
||||
static void test_rdhup_configured_node_still_elects(void)
|
||||
{
|
||||
install_stubs();
|
||||
g_guard_hook_calls = 0;
|
||||
|
||||
cluster_t c;
|
||||
myconf_t myconf;
|
||||
make_cluster(&c, &myconf, "ctrl-a");
|
||||
/* Configured node: real tag, >1 candidates — guard must NOT trip. */
|
||||
myconf.config_version_tag = TEST_CONFIG_TAG;
|
||||
myconf.peers_cfg.candidates_count = 2;
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0));
|
||||
ha_do_demote(&c.ha);
|
||||
c.ha.phase2_done = 1;
|
||||
c.ha.n_peers = 1;
|
||||
|
||||
ha_rdhup_election_hook = counting_election_hook;
|
||||
|
||||
int sv[2];
|
||||
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
|
||||
c.ha.primary_ctrl_fd = sv[0];
|
||||
|
||||
ha_on_primary_ctrl_fd_rdhup(&c.ha, &c);
|
||||
|
||||
clear_stubs();
|
||||
close(sv[1]);
|
||||
|
||||
/* Configured node: the election hook MUST fire (re-election proceeds). */
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_guard_hook_calls,
|
||||
"configured node (tag!=0, peers>1) must still start RDHUP re-election");
|
||||
|
||||
ha_fini(&c.ha);
|
||||
printf("test_rdhup_configured_node_still_elects: PASS (hook_calls=%d)\n",
|
||||
g_guard_hook_calls);
|
||||
}
|
||||
|
||||
/* ── Suite entry point ──────────────────────────────────────────────────── */
|
||||
|
||||
void test_ha_rdhup_integration(void)
|
||||
|
|
@ -385,4 +508,8 @@ void test_ha_rdhup_integration(void)
|
|||
* ack-counting promotion machinery removed, no shim) */
|
||||
RUN_TEST(test_rdhup_promotion_fires_replication_hooks);
|
||||
RUN_TEST(test_rdhup_primary_dispatch_gate_opens);
|
||||
/* Election-safety guard: config-less node must not re-elect; configured
|
||||
* node still does (ha-config-bootstrap Req 2.8 / ha-repl-hardening Req 10.5). */
|
||||
RUN_TEST(test_rdhup_noconfig_node_does_not_elect);
|
||||
RUN_TEST(test_rdhup_configured_node_still_elects);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@ int handle_request_vote(ha_state_t *ha,
|
|||
uint64_t my_last_lsn,
|
||||
int in_catch_up_flag);
|
||||
|
||||
/* Pure helper under test (election-safety fix #2): computes the voter's
|
||||
* effective freshness floor as the max of its local state and its learned
|
||||
* COMMITTED cluster state. */
|
||||
void ha_vote_self_freshness(ha_state_t *ha,
|
||||
uint64_t local_config_tag,
|
||||
uint64_t local_applied_lsn,
|
||||
uint64_t *out_tag,
|
||||
uint64_t *out_lsn);
|
||||
|
||||
/* ── Shared config for vote gate tests ──────────────────────────────────── */
|
||||
|
||||
static ha_config_t g_gate_cfg = {
|
||||
|
|
@ -229,6 +238,199 @@ static void test_electing_role_evaluates_vote(void)
|
|||
printf("test_electing_role_evaluates_vote: PASS\n");
|
||||
}
|
||||
|
||||
/* ══ Election-safety fix #2: deny candidates outdated vs LEARNED committed state ══
|
||||
*
|
||||
* A voter must measure candidate freshness against the strongest knowledge it
|
||||
* has of the cluster's COMMITTED state — not merely its own local config file /
|
||||
* applied LSN. ha->committed_config_tag and ha->last_known_committed_lsn carry
|
||||
* that learned-committed floor (from prior primary heartbeats / quorum ACK /
|
||||
* post-election safe point). The gate floor is max(local, learned). */
|
||||
|
||||
/* ── Direct unit test of ha_vote_self_freshness (max logic both fields) ───── */
|
||||
|
||||
static void test_vote_self_freshness_takes_max_of_local_and_learned(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
|
||||
/* learned-committed > local on tag, local > learned on lsn → expect the
|
||||
* per-field max of each. */
|
||||
atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release);
|
||||
atomic_store_explicit(&ha.last_known_committed_lsn, 20ULL, memory_order_release);
|
||||
|
||||
uint64_t out_tag = 0, out_lsn = 0;
|
||||
ha_vote_self_freshness(&ha, 40ULL /*local tag*/, 70ULL /*local lsn*/,
|
||||
&out_tag, &out_lsn);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(100ULL, out_tag,
|
||||
"freshness floor tag must be max(local=40, learned=100)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(70ULL, out_lsn,
|
||||
"freshness floor lsn must be max(local=70, learned=20)");
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_vote_self_freshness_takes_max_of_local_and_learned: PASS\n");
|
||||
}
|
||||
|
||||
/* ── RED: learned-committed config_tag floor denies an outdated candidate ─── */
|
||||
|
||||
static void test_learned_committed_tag_denies_outdated_candidate(void)
|
||||
{
|
||||
/* Voter local config is cold (my_config_tag=0) but it has LEARNED that the
|
||||
* cluster committed config_tag=100. A candidate offering config_tag=50
|
||||
* would roll the cluster back below the committed floor → must be DENIED.
|
||||
*
|
||||
* Before the fix the gate used the raw local my_config_tag=0, so cand=50 ≥ 0
|
||||
* granted (RED). After the fix the floor is max(0,100)=100 → deny (GREEN). */
|
||||
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.voted_for_term = 0;
|
||||
/* No active/fresh primary connection, not in catch-up → role gate opens. */
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale */
|
||||
|
||||
/* Learned committed floor: config_tag=100. */
|
||||
atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release);
|
||||
|
||||
int granted = handle_request_vote(&ha,
|
||||
1ULL,
|
||||
"ctrl-stale",
|
||||
50ULL, /* candidate config_tag — below learned floor */
|
||||
999ULL, /* high lsn — irrelevant once tag is below */
|
||||
0ULL, /* my local config_tag (cold) */
|
||||
0ULL, /* my local applied lsn (cold) */
|
||||
0);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"candidate below learned-committed config_tag floor must be DENIED");
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_learned_committed_tag_denies_outdated_candidate: PASS\n");
|
||||
}
|
||||
|
||||
/* ── RED: equal-tag, lower learned-committed LSN denies ──────────────────── */
|
||||
|
||||
static void test_learned_committed_lsn_denies_lower_lsn_candidate(void)
|
||||
{
|
||||
/* Voter has learned the cluster committed up to LSN=100 (tag stays 0).
|
||||
* Candidate matches the effective tag but offers last_lsn=50 < 100 →
|
||||
* would lose committed entries → must be DENIED. */
|
||||
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.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale */
|
||||
|
||||
/* Learned committed floor: lsn=100 (tag unchanged at 0). */
|
||||
atomic_store_explicit(&ha.last_known_committed_lsn, 100ULL, memory_order_release);
|
||||
|
||||
int granted = handle_request_vote(&ha,
|
||||
1ULL,
|
||||
"ctrl-laggard",
|
||||
0ULL, /* cand tag == effective tag (0) */
|
||||
50ULL, /* cand lsn < learned floor (100) */
|
||||
0ULL, /* my local config_tag */
|
||||
0ULL, /* my local applied lsn (cold) */
|
||||
0);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"equal-tag candidate below learned-committed LSN floor must be DENIED");
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_learned_committed_lsn_denies_lower_lsn_candidate: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Liveness: an up-to-date candidate is still GRANTED ──────────────────── */
|
||||
|
||||
static void test_uptodate_candidate_still_granted_with_learned_floor(void)
|
||||
{
|
||||
/* The freshest node must still be able to win — no liveness regression.
|
||||
* Candidate meets the learned floor exactly (tag=100, lsn=100). */
|
||||
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.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale */
|
||||
|
||||
atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release);
|
||||
atomic_store_explicit(&ha.last_known_committed_lsn, 100ULL, memory_order_release);
|
||||
|
||||
int granted = handle_request_vote(&ha,
|
||||
1ULL,
|
||||
"ctrl-fresh",
|
||||
100ULL, /* meets learned tag floor */
|
||||
100ULL, /* meets learned lsn floor */
|
||||
0ULL, /* local cold */
|
||||
0ULL,
|
||||
0);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
|
||||
"candidate meeting the learned-committed floor must still be GRANTED (liveness)");
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_uptodate_candidate_still_granted_with_learned_floor: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Backward-compat: no learned state (cold) falls back to local-only gate ─ */
|
||||
|
||||
static void test_no_learned_state_falls_back_to_local_gate(void)
|
||||
{
|
||||
/* committed_config_tag=0 and last_known_committed_lsn=0 → floor is the raw
|
||||
* local state, so behaviour is identical to the original local-only gate.
|
||||
* Local floor tag=100/lsn=50; candidate is below it → deny (unchanged). */
|
||||
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.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale */
|
||||
|
||||
/* No learned state. */
|
||||
atomic_store_explicit(&ha.committed_config_tag, 0ULL, memory_order_release);
|
||||
atomic_store_explicit(&ha.last_known_committed_lsn, 0ULL, memory_order_release);
|
||||
|
||||
int denied = handle_request_vote(&ha,
|
||||
1ULL,
|
||||
"ctrl-old",
|
||||
99ULL, /* below local tag 100 */
|
||||
999ULL,
|
||||
100ULL, /* local tag */
|
||||
50ULL, /* local lsn */
|
||||
0);
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, denied,
|
||||
"with no learned state the gate must behave as the local-only gate (deny lower tag)");
|
||||
|
||||
/* And an equally-fresh candidate (local-only) still grants. */
|
||||
ha_state_t ha2;
|
||||
ha_init(&ha2, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha2.role, (int32_t)CTRL_STANDBY, memory_order_release);
|
||||
atomic_store_explicit(&ha2.term, 1ULL, memory_order_release);
|
||||
ha2.voted_for_term = 0;
|
||||
ha2.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha2, 60000L);
|
||||
|
||||
int granted = handle_request_vote(&ha2,
|
||||
1ULL,
|
||||
"ctrl-eq",
|
||||
100ULL, /* == local tag */
|
||||
50ULL, /* == local lsn */
|
||||
100ULL,
|
||||
50ULL,
|
||||
0);
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
|
||||
"with no learned state an equally-fresh candidate still grants (local-only)");
|
||||
|
||||
ha_fini(&ha2);
|
||||
ha_fini(&ha);
|
||||
printf("test_no_learned_state_falls_back_to_local_gate: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Test suite entry point ───────────────────────────────────────────────── */
|
||||
|
||||
void test_ha_vote_gate(void)
|
||||
|
|
@ -247,4 +449,11 @@ void test_ha_vote_gate(void)
|
|||
|
||||
/* Req 6.4 — ELECTING evaluates */
|
||||
RUN_TEST(test_electing_role_evaluates_vote);
|
||||
|
||||
/* Election-safety fix #2 — learned-committed freshness floor */
|
||||
RUN_TEST(test_vote_self_freshness_takes_max_of_local_and_learned);
|
||||
RUN_TEST(test_learned_committed_tag_denies_outdated_candidate);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue