feat(election-iomgr-refactor): extend handle_request_vote with role-based deny gate — task 3.2
This commit is contained in:
parent
e6135ea828
commit
6de4b3ff0e
4 changed files with 286 additions and 1 deletions
|
|
@ -824,11 +824,34 @@ int ha_restore_term(ha_state_t *ha, const char *workdir)
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Vote Handler (Req 2.2–2.6, 9.1) ───────────────────────────────────────
|
/* ── ha_heartbeat_is_fresh (Req 6.2) ────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Returns 1 if the elapsed time since ha->last_heartbeat is strictly less than
|
||||||
|
* ha->config.heartbeat_timeout_ms (i.e. the primary is still alive).
|
||||||
|
* Returns 0 if the heartbeat is stale (timeout has expired).
|
||||||
|
*
|
||||||
|
* Uses CLOCK_MONOTONIC to avoid wall-clock skew.
|
||||||
|
*/
|
||||||
|
static int ha_heartbeat_is_fresh(ha_state_t *ha)
|
||||||
|
{
|
||||||
|
struct timespec now, last;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||||
|
pthread_mutex_lock(&ha->lock);
|
||||||
|
last = ha->last_heartbeat;
|
||||||
|
pthread_mutex_unlock(&ha->lock);
|
||||||
|
long ms = (now.tv_sec - last.tv_sec) * 1000L
|
||||||
|
+ (now.tv_nsec - last.tv_nsec) / 1000000L;
|
||||||
|
return ms < (long)ha->config.heartbeat_timeout_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Vote Handler (Req 2.2–2.6, 6.1, 6.2, 6.4, 9.1) ────────────────────────
|
||||||
*
|
*
|
||||||
* handle_request_vote: process an incoming OP_requestVote RPC.
|
* handle_request_vote: process an incoming OP_requestVote RPC.
|
||||||
*
|
*
|
||||||
* Decision order (fail-fast):
|
* Decision order (fail-fast):
|
||||||
|
* 0. role gate (Req 6.1, 6.2):
|
||||||
|
* - CTRL_PRIMARY or CTRL_PRIMARY_COMMITTING → deny
|
||||||
|
* - CTRL_STANDBY with fresh heartbeat → deny
|
||||||
* 1. in_catch_up → deny (Req 2.5)
|
* 1. in_catch_up → deny (Req 2.5)
|
||||||
* 2. req term < my term → deny (Req 2.2)
|
* 2. req term < my term → deny (Req 2.2)
|
||||||
* 3. req term > my term → update term, clear voted_for (Req 2.3)
|
* 3. req term > my term → update term, clear voted_for (Req 2.3)
|
||||||
|
|
@ -847,6 +870,17 @@ int handle_request_vote(ha_state_t *ha,
|
||||||
uint64_t my_last_lsn,
|
uint64_t my_last_lsn,
|
||||||
int in_catch_up_flag)
|
int in_catch_up_flag)
|
||||||
{
|
{
|
||||||
|
/* Req 6.1, 6.2: role-based deny gate — checked before any Raft state mutation.
|
||||||
|
* PRIMARY and PRIMARY_COMMITTING never grant votes (protect the live primary).
|
||||||
|
* STANDBY with a fresh heartbeat also denies (primary is alive, no new election
|
||||||
|
* needed). CTRL_INIT and CTRL_ELECTING fall through to Raft evaluation. */
|
||||||
|
ctrl_role_t my_role = (ctrl_role_t)atomic_load_explicit(&ha->role,
|
||||||
|
memory_order_acquire);
|
||||||
|
if (my_role == CTRL_PRIMARY || my_role == CTRL_PRIMARY_COMMITTING)
|
||||||
|
return 0; /* deny — I am primary (or committing); protect against challengers */
|
||||||
|
if (my_role == CTRL_STANDBY && ha_heartbeat_is_fresh(ha))
|
||||||
|
return 0; /* deny — my primary is alive */
|
||||||
|
|
||||||
/* Req 2.5: catching-up standbys deny all votes */
|
/* Req 2.5: catching-up standbys deny all votes */
|
||||||
if (in_catch_up_flag)
|
if (in_catch_up_flag)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ controller_unit_test_src = [
|
||||||
'testsuite/test_ha_failover.c',
|
'testsuite/test_ha_failover.c',
|
||||||
'testsuite/test_ha_vote_send.c',
|
'testsuite/test_ha_vote_send.c',
|
||||||
'testsuite/test_ha_start_election_async.c',
|
'testsuite/test_ha_start_election_async.c',
|
||||||
|
'testsuite/test_ha_vote_gate.c',
|
||||||
'startup_validation.c',
|
'startup_validation.c',
|
||||||
'worker.c',
|
'worker.c',
|
||||||
'persist.c',
|
'persist.c',
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ extern void test_ha_ctrlhello(void);
|
||||||
extern void test_ha_failover(void);
|
extern void test_ha_failover(void);
|
||||||
extern void test_ha_vote_send(void);
|
extern void test_ha_vote_send(void);
|
||||||
extern void test_ha_start_election_async(void);
|
extern void test_ha_start_election_async(void);
|
||||||
|
extern void test_ha_vote_gate(void);
|
||||||
|
|
||||||
void setUp(void) { return; }
|
void setUp(void) { return; }
|
||||||
|
|
||||||
|
|
@ -159,6 +160,7 @@ int main(int argc, char* argv[])
|
||||||
TEST(test_ha_failover);
|
TEST(test_ha_failover);
|
||||||
TEST(test_ha_vote_send);
|
TEST(test_ha_vote_send);
|
||||||
TEST(test_ha_start_election_async);
|
TEST(test_ha_start_election_async);
|
||||||
|
TEST(test_ha_vote_gate);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
248
src/controller/testsuite/test_ha_vote_gate.c
Normal file
248
src/controller/testsuite/test_ha_vote_gate.c
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
/* test_ha_vote_gate.c — Unity tests for Task 3.2: role-based vote-deny gate
|
||||||
|
* in handle_request_vote().
|
||||||
|
*
|
||||||
|
* Tests verify (Req 6.1, 6.2, 6.4):
|
||||||
|
* Req 6.1: CTRL_PRIMARY → deny all vote requests (return 0)
|
||||||
|
* Req 6.1: CTRL_PRIMARY_COMMITTING → deny all vote requests (return 0)
|
||||||
|
* Req 6.2: CTRL_STANDBY + fresh heartbeat → deny (return 0)
|
||||||
|
* Req 6.4: CTRL_STANDBY + stale heartbeat → evaluate Raft gates (not denied by role gate)
|
||||||
|
* Req 6.4: CTRL_ELECTING → evaluate Raft gates (not denied by role gate)
|
||||||
|
*
|
||||||
|
* Implementation gated on:
|
||||||
|
* - ha_heartbeat_is_fresh(ha): returns 1 if elapsed < heartbeat_timeout_ms
|
||||||
|
* - handle_request_vote() prepends role-based deny before existing Raft gates
|
||||||
|
*/
|
||||||
|
#define _GNU_SOURCE
|
||||||
|
#include <stdatomic.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <unity/unity.h>
|
||||||
|
|
||||||
|
#include "controller.capnp.h"
|
||||||
|
#include "ha.h"
|
||||||
|
|
||||||
|
/* Functions under test — declared here to match their signatures in ha.c */
|
||||||
|
int handle_request_vote(ha_state_t *ha,
|
||||||
|
uint64_t term,
|
||||||
|
const char *candidate_id,
|
||||||
|
uint64_t config_tag,
|
||||||
|
uint64_t last_lsn,
|
||||||
|
uint64_t my_config_tag,
|
||||||
|
uint64_t my_last_lsn,
|
||||||
|
int in_catch_up_flag);
|
||||||
|
|
||||||
|
/* ── Shared config for vote gate tests ──────────────────────────────────── */
|
||||||
|
|
||||||
|
static ha_config_t g_gate_cfg = {
|
||||||
|
.heartbeat_interval_ms = 3000,
|
||||||
|
.heartbeat_timeout_ms = 10000, /* 10 s — used for ha_heartbeat_is_fresh */
|
||||||
|
.election_timeout_ms = 2000,
|
||||||
|
.probe_connect_timeout_ms = 500,
|
||||||
|
.max_promotion_retries = 5,
|
||||||
|
.election_jitter_max_ms = 0,
|
||||||
|
.election_priority_step_ms = 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Helper: set ha->last_heartbeat to "now - offset_ms milliseconds ago" ── */
|
||||||
|
|
||||||
|
static void set_last_heartbeat_age(ha_state_t *ha, long offset_ms)
|
||||||
|
{
|
||||||
|
struct timespec now;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||||
|
|
||||||
|
long sec = offset_ms / 1000;
|
||||||
|
long nsec = (offset_ms % 1000) * 1000000L;
|
||||||
|
|
||||||
|
ha->last_heartbeat.tv_sec = now.tv_sec - sec;
|
||||||
|
ha->last_heartbeat.tv_nsec = now.tv_nsec - nsec;
|
||||||
|
|
||||||
|
/* Carry / borrow normalisation */
|
||||||
|
if (ha->last_heartbeat.tv_nsec < 0) {
|
||||||
|
ha->last_heartbeat.tv_sec -= 1;
|
||||||
|
ha->last_heartbeat.tv_nsec += 1000000000L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Req 6.1: CTRL_PRIMARY → deny regardless of candidate freshness ────── */
|
||||||
|
|
||||||
|
static void test_primary_role_denies_vote_request(void)
|
||||||
|
{
|
||||||
|
/* When the receiving node is CTRL_PRIMARY it must deny all vote requests
|
||||||
|
* without evaluating term, config_tag, or LSN. The candidate is perfectly
|
||||||
|
* fresh (same config_tag, same LSN, same term) — still deny. */
|
||||||
|
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.voted_for_term = 0;
|
||||||
|
|
||||||
|
int granted = handle_request_vote(&ha,
|
||||||
|
5ULL, /* same term — would pass term check */
|
||||||
|
"ctrl-b",
|
||||||
|
100ULL, /* same config_tag as mine */
|
||||||
|
50ULL, /* same LSN as mine */
|
||||||
|
100ULL, /* my config_tag */
|
||||||
|
50ULL, /* my last_lsn */
|
||||||
|
0 /* not in catch_up */);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||||
|
"CTRL_PRIMARY must deny all incoming vote requests (Req 6.1)");
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_primary_role_denies_vote_request: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Req 6.1: CTRL_PRIMARY_COMMITTING → deny ────────────────────────────── */
|
||||||
|
|
||||||
|
static void test_primary_committing_role_denies_vote_request(void)
|
||||||
|
{
|
||||||
|
/* When the receiving node is CTRL_PRIMARY_COMMITTING (waiting for quorum
|
||||||
|
* replication ack before opening traffic), it must also deny all vote
|
||||||
|
* requests. A challenger must not displace a committing primary. */
|
||||||
|
ha_state_t ha;
|
||||||
|
ha_init(&ha, &g_gate_cfg, 0);
|
||||||
|
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY_COMMITTING,
|
||||||
|
memory_order_release);
|
||||||
|
atomic_store_explicit(&ha.term, 3ULL, memory_order_release);
|
||||||
|
ha.voted_for_term = 0;
|
||||||
|
|
||||||
|
int granted = handle_request_vote(&ha,
|
||||||
|
3ULL, /* same term */
|
||||||
|
"ctrl-c",
|
||||||
|
200ULL, /* candidate is fresh */
|
||||||
|
100ULL,
|
||||||
|
100ULL, /* my config_tag */
|
||||||
|
50ULL,
|
||||||
|
0);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||||
|
"CTRL_PRIMARY_COMMITTING must deny all vote requests (Req 6.1)");
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_primary_committing_role_denies_vote_request: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Req 6.2: CTRL_STANDBY + fresh heartbeat → deny ────────────────────── */
|
||||||
|
|
||||||
|
static void test_standby_fresh_heartbeat_denies_vote_request(void)
|
||||||
|
{
|
||||||
|
/* When the receiving node is CTRL_STANDBY and received a heartbeat recently
|
||||||
|
* (within heartbeat_timeout_ms = 10 s), ha_heartbeat_is_fresh() returns 1
|
||||||
|
* and the function must deny the vote — my primary is alive. */
|
||||||
|
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.voted_for_term = 0;
|
||||||
|
|
||||||
|
/* last_heartbeat = 500 ms ago (< 10 000 ms timeout → fresh) */
|
||||||
|
set_last_heartbeat_age(&ha, 500L);
|
||||||
|
|
||||||
|
int granted = handle_request_vote(&ha,
|
||||||
|
2ULL,
|
||||||
|
"ctrl-b",
|
||||||
|
100ULL,
|
||||||
|
50ULL,
|
||||||
|
100ULL,
|
||||||
|
50ULL,
|
||||||
|
0);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||||
|
"CTRL_STANDBY with fresh heartbeat must deny vote requests (Req 6.2)");
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_standby_fresh_heartbeat_denies_vote_request: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Req 6.4: CTRL_STANDBY + stale heartbeat → evaluate Raft gates ──────── */
|
||||||
|
|
||||||
|
static void test_standby_stale_heartbeat_evaluates_vote(void)
|
||||||
|
{
|
||||||
|
/* When the receiving node is CTRL_STANDBY but the heartbeat is older than
|
||||||
|
* heartbeat_timeout_ms (10 s), ha_heartbeat_is_fresh() returns 0.
|
||||||
|
* The role gate must NOT deny — fall through to evaluate Raft gates.
|
||||||
|
*
|
||||||
|
* We set up a candidate with the same config_tag and LSN (freshness tie →
|
||||||
|
* grant) and no prior vote — the Raft logic should grant the vote. */
|
||||||
|
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;
|
||||||
|
|
||||||
|
/* last_heartbeat = 15 000 ms ago (> 10 000 ms timeout → stale) */
|
||||||
|
set_last_heartbeat_age(&ha, 15000L);
|
||||||
|
|
||||||
|
int granted = handle_request_vote(&ha,
|
||||||
|
1ULL,
|
||||||
|
"ctrl-a",
|
||||||
|
100ULL, /* same config_tag — freshness tie */
|
||||||
|
50ULL, /* same LSN — freshness tie */
|
||||||
|
100ULL,
|
||||||
|
50ULL,
|
||||||
|
0);
|
||||||
|
|
||||||
|
/* With stale heartbeat the role gate does not deny; Raft gates evaluate
|
||||||
|
* and should grant (equal freshness, no prior vote). */
|
||||||
|
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
|
||||||
|
"CTRL_STANDBY with stale heartbeat must evaluate Raft gates (Req 6.4)");
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_standby_stale_heartbeat_evaluates_vote: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Req 6.4: CTRL_ELECTING → evaluate Raft gates ───────────────────────── */
|
||||||
|
|
||||||
|
static void test_electing_role_evaluates_vote(void)
|
||||||
|
{
|
||||||
|
/* When the receiving node is CTRL_ELECTING (it is itself campaigning),
|
||||||
|
* the role gate must not deny — fall through to Raft term/freshness gates.
|
||||||
|
*
|
||||||
|
* A valid candidate (same term, fresh config_tag, no prior vote) must be
|
||||||
|
* granted, exercising the full Raft evaluation path. */
|
||||||
|
ha_state_t ha;
|
||||||
|
ha_init(&ha, &g_gate_cfg, 0);
|
||||||
|
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
|
||||||
|
atomic_store_explicit(&ha.term, 4ULL, memory_order_release);
|
||||||
|
ha.voted_for_term = 0;
|
||||||
|
|
||||||
|
int granted = handle_request_vote(&ha,
|
||||||
|
4ULL,
|
||||||
|
"ctrl-c",
|
||||||
|
300ULL, /* candidate fresher (higher tag) */
|
||||||
|
200ULL,
|
||||||
|
100ULL, /* my config_tag (lower) */
|
||||||
|
50ULL,
|
||||||
|
0);
|
||||||
|
|
||||||
|
/* Role gate does not fire; Raft gates evaluate; candidate is fresh → grant */
|
||||||
|
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
|
||||||
|
"CTRL_ELECTING must evaluate Raft gates, not deny via role gate (Req 6.4)");
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_electing_role_evaluates_vote: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test suite entry point ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
void test_ha_vote_gate(void)
|
||||||
|
{
|
||||||
|
/* Req 6.1 — PRIMARY denies */
|
||||||
|
RUN_TEST(test_primary_role_denies_vote_request);
|
||||||
|
|
||||||
|
/* Req 6.1 — PRIMARY_COMMITTING denies */
|
||||||
|
RUN_TEST(test_primary_committing_role_denies_vote_request);
|
||||||
|
|
||||||
|
/* Req 6.2 — STANDBY + fresh heartbeat denies */
|
||||||
|
RUN_TEST(test_standby_fresh_heartbeat_denies_vote_request);
|
||||||
|
|
||||||
|
/* Req 6.4 — STANDBY + stale heartbeat evaluates */
|
||||||
|
RUN_TEST(test_standby_stale_heartbeat_evaluates_vote);
|
||||||
|
|
||||||
|
/* Req 6.4 — ELECTING evaluates */
|
||||||
|
RUN_TEST(test_electing_role_evaluates_vote);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue