/* * 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 #include #include #include #include #include #include #include #include #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); }