feat(ha-single-leader-role): legacy election path symmetry — direct CTRL_PRIMARY via shared sequence (task 3.1)

This commit is contained in:
Rongsong Shen 2026-06-13 10:16:14 +00:00
parent c089fb69f3
commit b7b951b9e7
4 changed files with 167 additions and 46 deletions

View file

@ -28,6 +28,7 @@
#include <fcntl.h>
#include <inttypes.h>
#include <pthread.h>
#include <semaphore.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
@ -1053,6 +1054,113 @@ static void test_standalone_promotion_gate_opens_immediately(void)
#endif
}
/* ── Case 8: LEGACY/RAFT PROMOTION SYMMETRY (ha-single-leader-role task 3.1,
* Req 1.1, 1.5; design §Legacy Path Symmetry). Both entry points to PRIMARY
* must produce IDENTICAL observable state so test-driven and raft-driven
* promotion cannot diverge:
*
* (1) raft road — raft_cb_become_leader_for_test (the XCE_TESTING shim, which
* forces ha->raft_c == NULL): only the c-independent state is observable
* on this seam (role == CTRL_PRIMARY). The hook/gate tail is c!=NULL-
* guarded and skipped here — that half of the contract is pinned on the
* real-cluster road below (matching cases 3/4/5/7, which drive the SAME
* shared helpers ha_register_persist_hooks / ha_arm_own_term_gate).
*
* (2) legacy road — ha_elect_evaluate_and_signal on the real g_lc cluster
* (ctx->c = &g_lc, single-node quorum win): drives the full shared
* sequence. Must land role == CTRL_PRIMARY, hooks_registered == 1,
* writable_lsn == noop_lsn (own-term gate armed), and arm NO delaymgr
* watchdog — byte-for-byte the state ha_promote_to_primary produces.
*
* Pin: the legacy real-cluster path produces role/hook/gate/no-watchdog state
* identical to the raft road's role and to ha_promote_to_primary's full state.
* RED before task 3.1: the legacy branch stored CTRL_PRIMARY_COMMITTING, reset
* repl_ack_count, and armed the committing watchdog — diverging from the raft
* road on every axis. */
static void test_legacy_and_raft_promotion_are_symmetric(void)
{
#ifdef XCE_TESTING
/* ── Road 1: raft shim (c == NULL) — role contract only ──────────────── */
ha_state_t ha_raft;
ha_config_t cfg = lifecycle_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha_raft, &cfg, 0));
ha_raft.phase2_done = 1;
atomic_store_explicit(&ha_raft.role, (int32_t)CTRL_ELECTING,
memory_order_release);
raft_cb_become_leader_for_test(&ha_raft, /*term=*/7);
ctrl_role_t raft_role =
(ctrl_role_t)atomic_load_explicit(&ha_raft.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)raft_role,
"raft road must land DIRECTLY in CTRL_PRIMARY (Req 1.1)");
ha_fini(&ha_raft);
/* ── Road 2: legacy ha_elect_evaluate_and_signal on a real cluster ───── */
/* persist_wal_async appends at current_lsn then post-increments, and
* ha_arm_own_term_gate derives noop_lsn = current_lsn - 1; the fixture
* primes current_lsn = appended_lsn + 1, so passing appended_lsn =
* noop_lsn - 1 lands the own-term no-op at exactly lsn = noop_lsn
* (same idiom as case 5). */
const uint64_t noop_lsn = 9;
(void)lifecycle_cluster_setup("sym", /*n_peers=*/1,
/*n_standbys=*/0,
/*appended_lsn=*/noop_lsn - 1,
/*n_vcpus=*/8);
int before = delaymgr_task_count(&g_lc.delaymgr);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_lc.ha.hooks_registered,
"fixture precondition: hooks must be unregistered before promotion");
/* Single-node quorum win: n_peers==0 shortcircuit (votes[] empty),
* votes_granted=1 satisfies quorum=1. ctx->c = &g_lc so the shared
* promotion sequence runs against the real cluster. */
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx));
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "ctx calloc failed");
ctx->n_peers = 0;
ctx->votes_granted = 1;
ctx->n_replied = 0;
ctx->result = -1;
ctx->done_posted = 0;
ctx->c = &g_lc;
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
ctrl_role_t legacy_result = ha_elect_evaluate_and_signal(&g_lc.ha, ctx);
/* Symmetry axis 1: the legacy road reaches PRIMARY (matches the raft road). */
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)legacy_result,
"legacy evaluate must RESULT in CTRL_PRIMARY (Req 1.1)");
ctrl_role_t legacy_role =
(ctrl_role_t)atomic_load_explicit(&g_lc.ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)raft_role, (int)legacy_role,
"legacy and raft promotion must land in the SAME role (CTRL_PRIMARY)");
/* Symmetry axis 2: persist hooks registered by the shared sequence. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_lc.ha.hooks_registered,
"legacy road must register persist hooks via the shared sequence (Req 2.1)");
/* Symmetry axis 3: own-term gate armed at the no-op LSN. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn, g_lc.commit.writable_lsn,
"legacy road must arm the own-term gate at noop_lsn (Req 1.4, 2.1) — "
"identical to the raft road's shared tail");
/* Symmetry axis 4: NO committing-timeout watchdog armed (Req 1.5). */
int after = delaymgr_task_count(&g_lc.delaymgr);
TEST_ASSERT_EQUAL_INT_MESSAGE(before, after,
"legacy road must arm NO delaymgr watchdog (Req 1.5) — symmetric with "
"the raft road; the committing-timeout arm is deleted from this path");
pthread_mutex_destroy(&ctx->lock);
sem_destroy(&ctx->done);
free(ctx);
lifecycle_cluster_teardown();
printf("test_legacy_and_raft_promotion_are_symmetric: PASS "
"(role=%d both roads; hooks=1; writable_lsn=%" PRIu64
"; watchdog tasks before=%d after=%d)\n",
(int)legacy_role, g_lc.commit.writable_lsn, before, after);
#else
printf("test_legacy_and_raft_promotion_are_symmetric: SKIP (non-test build)\n");
#endif
}
/* ------------------------------------------------------------------ */
/* Entry point (registered in controller_test.c / meson.build) */
/* ------------------------------------------------------------------ */
@ -1073,4 +1181,7 @@ void test_ha_role_lifecycle(void)
RUN_TEST(test_promotion_appends_noop_and_arms_own_term_gate); /* case 5 */
RUN_TEST(test_pre_own_term_wait_has_no_third_path); /* case 6 */
RUN_TEST(test_standalone_promotion_gate_opens_immediately); /* case 7 */
/* Task 3.1 — legacy/raft promotion symmetry (Req 1.1, 1.5) */
RUN_TEST(test_legacy_and_raft_promotion_are_symmetric); /* case 8 */
}