myxce/src/controller/testsuite/test_ha_repl_push.c

311 lines
11 KiB
C

/*
* test_ha_repl_push.c — Unity tests for Task 4.2:
* Config and WAL replication push to standbys in the COMMITTING path.
*
* Tests verify:
* Req 7.1: When entering CTRL_PRIMARY_COMMITTING, push_config_to_standbys
* is called (via ha_push_config_hook seam).
* Req 7.2: When entering CTRL_PRIMARY_COMMITTING, ha_send_ctrlhello_to_all_standbys
* is called to trigger catch-up replication (via ha_send_ctrlhello_hook seam).
* Req 7.3: The push is triggered at the point of COMMITTING entry, not before.
* When election is lost (CTRL_STANDBY), no push occurs.
*
* Design ref: design.md "ha_send_ctrlhello_to_all_standbys" section
* Boundary: ha.c (election evaluation + COMMITTING path)
* Requirements: 7.1, 7.2, 7.3
*/
#define _GNU_SOURCE
#include <errno.h>
#include <semaphore.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unity/unity.h>
#include "ha.h"
/* ── Test-only seam: ha_send_ctrlhello_to_all_standbys call hook ─────────────
*
* In production, ha_elect_evaluate_and_signal calls
* ha_send_ctrlhello_to_all_standbys(c) in the COMMITTING path.
* For unit tests, the real function is not linked (cluster.c not in test binary).
* The XCE_TESTING seam in ha.c checks ha_send_ctrlhello_hook; if non-NULL, it
* calls the hook instead of the real ha_send_ctrlhello_to_all_standbys.
*/
extern int (*ha_send_ctrlhello_hook)(void *c);
/* ── Test-only seam: push_config_to_standbys call hook ──────────────────────
*
* In production, ha_elect_evaluate_and_signal calls push_config_to_standbys(c,...)
* in the COMMITTING path. For unit tests, the seam in ha.c checks
* ha_push_config_hook; if non-NULL, it calls the hook instead.
*/
extern int (*ha_push_config_hook)(void *c);
/* ── Call counters for hooks ─────────────────────────────────────────────── */
static int g_ctrlhello_call_count = 0;
static int g_push_config_call_count = 0;
static int stub_ha_send_ctrlhello(void *c)
{
(void)c;
g_ctrlhello_call_count++;
return 0;
}
static int stub_ha_push_config(void *c)
{
(void)c;
g_push_config_call_count++;
return 0;
}
/* Also need the phase2 hook since ha_elect_evaluate_and_signal calls it */
extern int (*ha_phase2_hook)(void *c);
static int stub_phase2(void *c)
{
(void)c;
return 0;
}
/* ── Minimal ha_config_t for tests ─────────────────────────────────────── */
static ha_config_t g_cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 10000,
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* ── Helper: build a minimal ha_elect_ctx_t ─────────────────────────────── */
static ha_elect_ctx_t *make_ctx(int n_other, int votes_granted)
{
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) +
(size_t)n_other * sizeof(ctx->votes[0]));
if (!ctx) return NULL;
ctx->n_peers = n_other;
ctx->votes_granted = votes_granted;
ctx->n_replied = n_other;
ctx->max_resp_tag = 0;
ctx->max_resp_lsn = 0;
ctx->result = -1;
ctx->done_posted = 0;
ctx->c = NULL; /* no real cluster in unit tests */
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
if (n_other > 0) {
ctx->votes[0].my_tag = 100;
ctx->votes[0].my_lsn = 50;
}
return ctx;
}
static void free_ctx(ha_elect_ctx_t *ctx)
{
if (!ctx) return;
pthread_mutex_destroy(&ctx->lock);
sem_destroy(&ctx->done);
free(ctx);
}
/* ── Setup / teardown ────────────────────────────────────────────────────── */
static void reset_counters(void)
{
g_ctrlhello_call_count = 0;
g_push_config_call_count = 0;
ha_send_ctrlhello_hook = stub_ha_send_ctrlhello;
ha_push_config_hook = stub_ha_push_config;
ha_phase2_hook = stub_phase2;
}
static void clear_hooks(void)
{
ha_send_ctrlhello_hook = NULL;
ha_push_config_hook = NULL;
ha_phase2_hook = NULL;
}
/* ── Test 1: Req 7.2 — COMMITTING path calls ha_send_ctrlhello_to_all_standbys ── */
static void test_committing_calls_ctrlhello_push(void)
{
/* When election is won (CTRL_PRIMARY → COMMITTING), ha_send_ctrlhello_to_all_standbys
* must be called exactly once.
* Single-node wins: n_other=0, votes_granted=1. */
reset_counters();
ha_state_t ha;
ha_init(&ha, &g_cfg, 0);
ha.phase2_done = 1; /* skip phase2 */
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
ha_elect_ctx_t *ctx = make_ctx(0, 1); /* single-node wins */
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "make_ctx failed");
ha_elect_evaluate_and_signal(&ha, ctx);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_ctrlhello_call_count,
"ha_send_ctrlhello_to_all_standbys must be called once on COMMITTING entry (Req 7.2)");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_committing_calls_ctrlhello_push: PASS (ctrlhello_calls=%d)\n",
g_ctrlhello_call_count);
}
/* ── Test 2: Req 7.1 — COMMITTING path calls push_config_to_standbys ──────── */
static void test_committing_calls_push_config(void)
{
/* When election is won, push_config_to_standbys must be called once. */
reset_counters();
ha_state_t ha;
ha_init(&ha, &g_cfg, 0);
ha.phase2_done = 1;
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
ha_elect_ctx_t *ctx = make_ctx(0, 1);
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "make_ctx failed");
ha_elect_evaluate_and_signal(&ha, ctx);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_push_config_call_count,
"push_config_to_standbys must be called once on COMMITTING entry (Req 7.1)");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_committing_calls_push_config: PASS (push_config_calls=%d)\n",
g_push_config_call_count);
}
/* ── Test 3: Req 7.3 — losing election → no push ───────────────────────── */
static void test_standby_result_does_not_push(void)
{
/* When the election is lost (CTRL_STANDBY), neither push function is called. */
reset_counters();
ha_state_t ha;
ha_init(&ha, &g_cfg, 0);
ha.phase2_done = 1;
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
/* n_other=2, quorum=2, votes_granted=1 → loses */
ha_elect_ctx_t *ctx = make_ctx(2, 1);
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "make_ctx failed");
ctx->votes[0].my_tag = 100;
ctx->votes[0].my_lsn = 50;
ctrl_role_t result = ha_elect_evaluate_and_signal(&ha, ctx);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_STANDBY, (int)result,
"election with votes_granted=1 of n_other=2 must lose");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_ctrlhello_call_count,
"ha_send_ctrlhello_to_all_standbys must NOT be called when standby (Req 7.3)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_push_config_call_count,
"push_config_to_standbys must NOT be called when standby (Req 7.3)");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_standby_result_does_not_push: PASS "
"(ctrlhello=%d, push_config=%d)\n",
g_ctrlhello_call_count, g_push_config_call_count);
}
/* ── Test 4: 3-node win also triggers both pushes ───────────────────────── */
static void test_3node_win_calls_both_pushes(void)
{
/* n_other=2, quorum=2, votes_granted=3 → wins.
* Both push hooks must be called exactly once. */
reset_counters();
ha_state_t ha;
ha_init(&ha, &g_cfg, 0);
ha.phase2_done = 1;
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
ha_elect_ctx_t *ctx = make_ctx(2, 3); /* votes_granted=3 (self + 2 peers) */
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "make_ctx failed");
ctx->votes[0].my_tag = 100;
ctx->votes[0].my_lsn = 50;
ctrl_role_t result = ha_elect_evaluate_and_signal(&ha, ctx);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)result,
"3-node win must return CTRL_PRIMARY");
ctrl_role_t actual_role =
(ctrl_role_t)atomic_load_explicit(&ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY_COMMITTING, (int)actual_role,
"ha->role must be CTRL_PRIMARY_COMMITTING after winning");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_ctrlhello_call_count,
"ha_send_ctrlhello_to_all_standbys must be called once (3-node win)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_push_config_call_count,
"push_config_to_standbys must be called once (3-node win)");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_3node_win_calls_both_pushes: PASS "
"(ctrlhello=%d, push_config=%d)\n",
g_ctrlhello_call_count, g_push_config_call_count);
}
/* ── Test 5: done_posted guard prevents double push ─────────────────────── */
static void test_done_posted_prevents_double_push(void)
{
/* Second call to ha_elect_evaluate_and_signal on same ctx is a no-op
* (done_posted guard): neither push hook should fire a second time. */
reset_counters();
ha_state_t ha;
ha_init(&ha, &g_cfg, 0);
ha.phase2_done = 1;
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
ha_elect_ctx_t *ctx = make_ctx(0, 1);
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "make_ctx failed");
ha_elect_evaluate_and_signal(&ha, ctx); /* first: triggers pushes (count=1) */
ha_elect_evaluate_and_signal(&ha, ctx); /* second: done_posted guard → no-op */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_ctrlhello_call_count,
"done_posted guard must prevent double ctrlhello push");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_push_config_call_count,
"done_posted guard must prevent double config push");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_done_posted_prevents_double_push: PASS "
"(ctrlhello=%d, push_config=%d)\n",
g_ctrlhello_call_count, g_push_config_call_count);
}
/* ── Suite entry point ───────────────────────────────────────────────────── */
void test_ha_repl_push(void)
{
RUN_TEST(test_committing_calls_ctrlhello_push);
RUN_TEST(test_committing_calls_push_config);
RUN_TEST(test_standby_result_does_not_push);
RUN_TEST(test_3node_win_calls_both_pushes);
RUN_TEST(test_done_posted_prevents_double_push);
}