From 6de4b3ff0e26145373e470fa91ecabeb70cd37d8 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sat, 30 May 2026 11:03:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(election-iomgr-refactor):=20extend=20handl?= =?UTF-8?q?e=5Frequest=5Fvote=20with=20role-based=20deny=20gate=20?= =?UTF-8?q?=E2=80=94=20task=203.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/ha.c | 36 ++- src/controller/meson.build | 1 + src/controller/testsuite/controller_test.c | 2 + src/controller/testsuite/test_ha_vote_gate.c | 248 +++++++++++++++++++ 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/controller/testsuite/test_ha_vote_gate.c diff --git a/src/controller/ha.c b/src/controller/ha.c index eb3d2704..f5218c07 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -824,11 +824,34 @@ int ha_restore_term(ha_state_t *ha, const char *workdir) 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. * * 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) * 2. req term < my term → deny (Req 2.2) * 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, 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 */ if (in_catch_up_flag) return 0; diff --git a/src/controller/meson.build b/src/controller/meson.build index 93f9c424..224caf60 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -113,6 +113,7 @@ controller_unit_test_src = [ 'testsuite/test_ha_failover.c', 'testsuite/test_ha_vote_send.c', 'testsuite/test_ha_start_election_async.c', + 'testsuite/test_ha_vote_gate.c', 'startup_validation.c', 'worker.c', 'persist.c', diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index 58d84470..8d3a2a89 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -45,6 +45,7 @@ extern void test_ha_ctrlhello(void); extern void test_ha_failover(void); extern void test_ha_vote_send(void); extern void test_ha_start_election_async(void); +extern void test_ha_vote_gate(void); void setUp(void) { return; } @@ -159,6 +160,7 @@ int main(int argc, char* argv[]) TEST(test_ha_failover); TEST(test_ha_vote_send); TEST(test_ha_start_election_async); + TEST(test_ha_vote_gate); return UNITY_END(); } diff --git a/src/controller/testsuite/test_ha_vote_gate.c b/src/controller/testsuite/test_ha_vote_gate.c new file mode 100644 index 00000000..78cc1ded --- /dev/null +++ b/src/controller/testsuite/test_ha_vote_gate.c @@ -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 +#include +#include +#include +#include +#include + +#include + +#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); +}