feat(ha-config-bootstrap): implement vote handlers and term persistence (task 4.2)

- ha_persist_term(): atomic write+rename of ha_term_file_t with CRC32
- handle_request_vote(): term/voted_for/freshness/catch-up gate chain
- handle_request_vote_reply(): vote counting, quorum+all-replied signaling
- ha_elect_evaluate_and_signal(): quorum+yield+zero-config gates (4.3 early)
- am_i_primary_quorum(): single-node shortcircuit + 2-round retry (4.3 partial)
- 24 Tests 0 Failures in test_ha_election suite
This commit is contained in:
Rongsong Shen 2026-05-28 07:32:48 +00:00
parent 403dd17f8b
commit 0f88d5d32a
4 changed files with 538 additions and 1 deletions

View file

@ -110,6 +110,11 @@ ctrl_role_t am_i_primary_quorum(ha_state_t *ha, int n_peers,
xce_candidate_t **peers,
uint64_t my_config_tag, uint64_t my_lsn);
void handle_request_vote_reply(ha_state_t *ha, ha_elect_ctx_t *ctx,
int peer_idx, uint64_t term,
int vote_granted,
uint64_t peer_config_tag, uint64_t peer_lsn);
/* ── Minimal ha_config_t used across all election tests ─────────────────── */
static ha_config_t g_elect_cfg = {
@ -803,6 +808,123 @@ static void test_vote_request_from_self_same_term_granted(void)
printf("test_vote_request_from_self_same_term_granted: PASS\n");
}
/* ── handle_request_vote_reply tests ────────────────────────────────────────
*
* These tests verify the reply handler's counting and signalling logic.
* They use a 3-node cluster (n_peers=2 other peers) so quorum = ceil(3/2) = 2.
* self-vote starts at 1; receiving one granted reply raises votes_granted to 2,
* which meets quorum — the handler must post ctx->done at that point.
*/
/* Req 4.2: one granted reply in a 3-node cluster reaches quorum → done posted */
static void test_reply_handler_quorum_reached_posts_done(void)
{
/* 3-node cluster: n_peers=2. quorum=2. votes_granted starts at 1 (self). */
ha_state_t ha;
ha_init(&ha, &g_elect_cfg, 0);
atomic_store_explicit(&ha.term, 1ULL, memory_order_release);
ha_elect_ctx_t *ctx = alloc_elect_ctx(2);
/* Record candidate's own tag/lsn so the evaluator can apply the yield gate */
ctx->votes[0].my_tag = 100ULL;
ctx->votes[0].my_lsn = 50ULL;
/* Peer 0 grants the vote with equal freshness → votes_granted becomes 2 = quorum */
handle_request_vote_reply(&ha, ctx,
0, /* peer_idx */
1ULL, /* term */
1, /* vote_granted */
100ULL, /* peer_config_tag (equal to candidate) */
50ULL /* peer_lsn */);
/* ctx->done should have been posted — sem_trywait must succeed */
int rc = sem_trywait(&ctx->done);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"ctx->done must be posted when quorum is reached after first granted reply");
/* Tallies must be updated correctly */
TEST_ASSERT_EQUAL_INT(2, ctx->votes_granted); /* self + peer 0 */
TEST_ASSERT_EQUAL_INT(1, ctx->n_replied);
TEST_ASSERT_EQUAL_INT(1, ctx->votes[0].replied);
TEST_ASSERT_EQUAL_INT(1, ctx->votes[0].vote_granted);
free_elect_ctx(ctx);
ha_fini(&ha);
printf("test_reply_handler_quorum_reached_posts_done: PASS\n");
}
/* Req 4.2: partial replies that do not yet reach quorum must NOT post done */
static void test_reply_handler_partial_replies_do_not_post_done(void)
{
/* 5-node cluster: n_peers=4. quorum = ceil(5/2) = 3.
* votes_granted starts at 1 (self). One granted reply → votes_granted=2 < 3.
* n_replied=1 < 4. Neither threshold met → done must NOT be posted. */
ha_state_t ha;
ha_init(&ha, &g_elect_cfg, 0);
atomic_store_explicit(&ha.term, 2ULL, memory_order_release);
ha_elect_ctx_t *ctx = alloc_elect_ctx(4);
ctx->votes[0].my_tag = 200ULL;
ctx->votes[0].my_lsn = 100ULL;
/* Peer 0 grants; only 1 of 4 peers have replied → still below both thresholds */
handle_request_vote_reply(&ha, ctx,
0, /* peer_idx */
2ULL, /* term */
1, /* vote_granted */
200ULL, /* peer_config_tag */
100ULL /* peer_lsn */);
/* ctx->done must NOT have been posted — sem_trywait must fail with EAGAIN */
int rc = sem_trywait(&ctx->done);
TEST_ASSERT_EQUAL_INT_MESSAGE(-1, rc,
"ctx->done must NOT be posted when quorum is not yet reached");
TEST_ASSERT_EQUAL_INT(EAGAIN, errno);
/* Tallies should still be updated */
TEST_ASSERT_EQUAL_INT(2, ctx->votes_granted);
TEST_ASSERT_EQUAL_INT(1, ctx->n_replied);
free_elect_ctx(ctx);
ha_fini(&ha);
printf("test_reply_handler_partial_replies_do_not_post_done: PASS\n");
}
/* Req 4.2: all peers reply with denials → n_replied reaches n_peers → done posted */
static void test_reply_handler_all_denied_posts_done_on_last_reply(void)
{
/* 3-node cluster: n_peers=2. Both peers deny → votes_granted stays 1 < quorum(2).
* After second deny n_replied=2 = n_peers → done is posted (CTRL_STANDBY result). */
ha_state_t ha;
ha_init(&ha, &g_elect_cfg, 0);
atomic_store_explicit(&ha.term, 1ULL, memory_order_release);
ha_elect_ctx_t *ctx = alloc_elect_ctx(2);
ctx->votes[0].my_tag = 100ULL;
ctx->votes[0].my_lsn = 50ULL;
/* Peer 0 denies */
handle_request_vote_reply(&ha, ctx, 0, 1ULL, 0, 100ULL, 50ULL);
/* done must not be posted yet (1 of 2 replied, votes_granted=1 < quorum=2) */
int rc = sem_trywait(&ctx->done);
TEST_ASSERT_EQUAL_INT(-1, rc);
TEST_ASSERT_EQUAL_INT(EAGAIN, errno);
/* Peer 1 denies — now all 2 peers replied → done must be posted */
handle_request_vote_reply(&ha, ctx, 1, 1ULL, 0, 100ULL, 50ULL);
rc = sem_trywait(&ctx->done);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"ctx->done must be posted when all peers have replied (even if all deny)");
TEST_ASSERT_EQUAL_INT(1, ctx->votes_granted); /* only self-vote */
TEST_ASSERT_EQUAL_INT(2, ctx->n_replied);
TEST_ASSERT_EQUAL_INT(0, ctx->result); /* CTRL_STANDBY */
free_elect_ctx(ctx);
ha_fini(&ha);
printf("test_reply_handler_all_denied_posts_done_on_last_reply: PASS\n");
}
/* ── Test suite entry point ───────────────────────────────────────────────── */
void test_ha_election(void)
@ -853,4 +975,9 @@ void test_ha_election(void)
/* Misc / self-vote */
RUN_TEST(test_vote_request_from_self_same_term_granted);
/* handle_request_vote_reply — reply handler counting and signalling */
RUN_TEST(test_reply_handler_quorum_reached_posts_done);
RUN_TEST(test_reply_handler_partial_replies_do_not_post_done);
RUN_TEST(test_reply_handler_all_denied_posts_done_on_last_reply);
}